Decorators no JavaScript
Decorators são uma das mais antigas propostas do JavaScript. Quantas vezes você já não ouviu que "O JavaScript vai ter decorators em breve"? Mas o que são esses decorators e o que eles vão mudar na nossa vida? Hoje ela está em estágio 3, o que significa que o tempo para ela ir ao ar reduz drásticamente, mas ainda sim não temos uma resposta concreta.
Se você não sabe como funciona o JavaScript, nesse vídeo eu explico um pouco mais sobre o processo de lançamento de novas funcionalidades do JavaScript, se você ainda não assistiu, eu recomendo fortemente para poder entender melhor como tudo funciona!
Decorators
Decorators são o nome curto para as decorator functions, que é um padrão de projeto, inclusive. Eles são uma função (ou método) que modificam o comportamento de outra função passada, retornando uma nova função.
Essencialmente você pode implementar decorators em qualquer linguagem, afinal eles são um padrão de projetos. No JavaScript você poderiam implementar um decorator da seguinte forma:
const decorator = (fn) => {
return (...params) => {
console.log('antes da função')
const resultado = fn.call(this, ...params)
console.log('depois da função')
return resultado
}
}
const func = (nome) => console.log(`Olá ${nome}`)
const decorada = decorator(func)
decorada('Lucas')
// antes da função
// Olá Lucas
// depois da função
Porém, algumas linguagens possuem uma sintaxe especial para chamar decorators, como Python e Java, por exemplo, veja como podemos criar um decorator em Python:
def decorator(fn):
def wrap():
print("antes da função")
fn()
print("depois da função")
return wrap
@decorator
def sayHello():
print("hello!")
sayHello()
# antes da função
# hello!
# depois da função
Percebe que temos um @decorator
? Essa é a sintaxe mais utilizada para chamarmos um decorator na função que vem logo em seguida.
A maioria das linguagens permite que decorators sejam aplicados em diversos locais, como classes, métodos, propriedades e etc. No JavaScript esse não foi sempre o caso, a versão anterior da proposta (que estava no estágio 2) dizia que os decorators só poderiam ser aplicados à classes e a nenhum outro tipo de objeto.
Com a nova proposta, os decorators podem ser aplicados nos seguintes tipos de objetos:
- Classes (como já eram aplicados antes)
- Propriedades de classes
- Métodos de classes
- Acessores de classes
Ou seja, ainda estamos focando na classe, mas não é mais somente na instância da classe, mas sim em tudo que vem dentro dela.
Usando decorators
Decorators são essencialmente funções, como vimos antes. Todas essas funções vão levar dois parâmetros:
- O valor que está sendo decorado, que é o elemento que aquele decorator está aplicado
- Um objeto de contexto, contendo informações sobre o valor decorado
Tenha em mente que o valor decorado é uma referência para o objeto original, ou seja, qualquer mudança nesse valor, vai interferir com o valor original.
O tipo declarado (tirado da proposta) é exatamente esse:
type Decorator = (value: Input, context: {
kind: string;
name: string | symbol;
access: {
get?(): unknown;
set?(value: unknown): void;
};
private?: boolean;
static?: boolean;
addInitializer?(initializer: () => void): void;
}) => Output | void;
Nesse tipo, Input
e Output
representam, respectivamente, o objeto que você está decorando e o retorno do decorator, que é uma função. Cada tipo de decorator pode retornar um tipo de função diferente e tem um tipo de input diferente, seja ele um decorator de classe, propriedade ou acessor.
O objeto de contexto também varia de acordo com o valor que você está decorando, então ele pode ou não pode conter alguns dos campos, por exemplo, o campo access
só existe para acessores.
As demais propriedades tem valores bem fixos, por exemplo:
kind
é o tipo do objeto que você está decorando, essa propriedade existe basicamente para verificar se você está usando o decorator corretamente e buscando as propriedades corretas. Os valores possíveis são:class
,method
,getter
,setter
,field
eaccessor
name
é o nome do objeto decorado, no caso de elementos privados vai ser a descrição (que é o próprio nome da propriedade)access
um objeto que contém duas possíveis chavesget
eset
que são funções usadas para acessar o valor decorado. É importante notar que esses valores são os valores finais que são passados para a instância do objeto, e não o valor que foi passado para o decoratorstatic
indica se o valor é um elemento estático de uma classe, portanto só se aplica para elementos que podem ser estáticosprivate
indica se o elemento é privado, e tem a mesma regra dostatic
addInitializer
é uma função extra que permite que você adicione uma lógica de inicialização do objeto decorado, todos os tipos possuem essa funcionalidade e ele opera por classe e não por instância, ou seja, ele só vai executar em objetos que não temkind === 'field'
Ordem de aplicação
Assim como todos os elementos que suportam vários tipos de uso, os decorators são aplicados em uma ordem.
Primeiro eles só são aplicados quando todos foram chamados. Depois disso, os decorators são aplicados da menor ordem para a maior ordem, isso significa que primeiramente todos os decorators de métodos e campos são chamados e aplicados e depois os decorators de classe são aplicados e, por fim, todos os decorators de campos estáticos são aplicados.
Além disso, não existem regras especiais quanto a qual tipo de função pode ser usada como um decorator, desde que ela siga a assinatura proposta, qualquer função pode ser aplicada como um decorator.
Tipos de decorators
Vamos agora passar um por um com os tipos de decorators que temos nessa proposta, começando com a maior ordem e descendo para as ordens menores.
Class methods
Decorators aplicados em métodos de classe como a seguir:
class foo {
@dec
metodo (arg) {}
}
Esse tipo de decorator segue a seguinte tipagem:
type ClassMethodDecorator = (value: Function, context: {
kind: "method";
name: string | symbol;
access: { get(): unknown };
static: boolean;
private: boolean;
addInitializer(initializer: () => void): void;
}) => Function | void;
Veja que o kind
vai ser sempre method
e o acessor vai ter somente o método get
, uma vez que você não pode dar set
em um método.
O parâmetro value
é o método que está sendo decorado, além disso o decorator pode ou não retornar um novo método que vai substituir o método que está sendo decorado, se ele não retornar nada então o método será executado normalmente.
Um exemplo clássico é o decorator de log que mostrei no início do artigo, podemos criar um novo decorator para realizar um log do que está sendo executado pelo método, executar o método em si e depois retornar o resultado:
function debug(value, { kind, name }) {
if (kind === "method") {
return function (...args) {
console.log(`começando ${name} com os argumentos ${args.join(", ")}`);
const ret = value.call(this, ...args);
console.log(`fim de ${name}`);
return ret;
};
}
}
class C {
@debug
m(arg) {}
}
new C().m(1);
// começando m com os argumentos 1
// fim de m
Veja que neste caso estamos retornando uma função que vai substituir o método na classe original (o protótipo vai ser substituído), se não retornássemos nada, somente o decorator seria executado.
Se quiséssemos fazer isso sem usar os decorators, podemos imaginar que temos a classe e estamos substituindo o método m
no protótipo diretamente pela chamada com o nosso decorator:
class C {
m(arg) {}
}
C.prototype.m = debug(C.prototype.m, { kind: 'method', name: 'm' }) ?? C.prototype.m
Acessores de classe
Os acessores de classes (como get
e set
) podem ter duas assinaturas dependendo do tipo de acessor que estamos falando, para get
:
type ClassGetterDecorator = (value: Function, context: {
kind: "getter";
name: string | symbol;
access: { get(): unknown };
static: boolean;
private: boolean;
addInitializer(initializer: () => void): void;
}) => Function | void;
E para o set
a diferença é que o kind
vai ser setter
e vamos ter um access
com uma função set
:
type ClassSetterDecorator = (value: Function, context: {
kind: "setter";
name: string | symbol;
access: { set(value: unknown): void };
static: boolean;
private: boolean;
addInitializer(initializer: () => void): void;
}) => Function | void;
O funcionamento é exatamente igual aos decorators de métodos, porém é importante notar que os decorators de acessores são aplicados separadamente para getters e setters ou seja:
class C {
@foo
get x() {
// ...
}
set x(val) {
// ...
}
}
Nessa classe, o decorator está decorando apenas o get x()
e não o set x(val)
. Eles são tão iguais que podemos reutilizar a mesma função debug
que tínhamos anteriormente, só precisamos tratar os novos tipos de kind
:
function debug(value, { kind, name }) {
if (['method', 'getter', 'setter'].contains(kind)) {
return function (...args) {
console.log(`começando ${name} com os argumentos ${args.join(", ")}`);
const ret = value.call(this, ...args);
console.log(`fim de ${name}`);
return ret;
};
}
}
class C {
@debug
set x(arg) {}
}
new C().x = 1
// começando x com os argumentos 1
// fim de x
Da mesma forma, podemos aplicar essa funcionalidade sem o uso de decorators usando Object.defineProperty
:
class C {
set x(arg) {}
}
let { set } = Object.getOwnPropertyDescriptor(C.prototype, "x");
set = debug(set, {
kind: "setter",
name: "x",
static: false,
private: false,
}) ?? set;
Object.defineProperty(C.prototype, "x", { set });
Propriedades de classe (class fields)
Esse tipo de decorator usa a tipagem completa:
type ClassFieldDecorator = (value: undefined, context: {
kind: "field";
name: string | symbol;
access: { get(): unknown, set(value: unknown): void };
static: boolean;
private: boolean;
}) => (initialValue: unknown) => unknown | void;
Ele possui tanto os acessores get
quanto o set
, além de possuir as propriedades static
e private
, porém, diferente dos demais, ele não possui um método addInitializer
já que propriedades não podem ser inicializadas dessa forma.
Também, diferente dos demais tipos de decorators, como propriedades não tem um valor direto de input, portanto o value
é sempre undefined
ou seja, você não recebe a propriedade e nem uma referência dela, ao invés disso você pode retornar uma função que recebe o valor inicial e retorna um novo valor sempre que a propriedade é atribuída.
Para podermos usar a nossa função de debug nesses casos, vamos precisar de uma pequena modificação, já que não podemos retornar uma função nova mas sim um valor inicial.
function debug (_, {kind, name}) {
if (king === 'field') {
return function (initialValue) {
console.log(`inicializando variável ${name} com valor ${initialValue}`)
return initialValue
}
}
}
E então podemos usar o nosso campo da seguinte maneira:
class C {
@debug x = 1
}
new C()
// Inicializando variável x com valor 1
E podemos implementar esse mesmo comportamento usando uma chamada de inicialização na propriedade:
const inicializarX = debug(undefined, { kind: 'field', name: 'x' }) ?? (initialValue) => initialValue
class C {
x = inicializarX.call(this, 1)
}
Um dos exemplos interessantes que a própria proposta apresenta é que, como a função de inicialização é chamada com a instância da classe como this
, então esse tipo de decorator pode ser utilizado para criar relações de inicialização, tipo registrar uma classe filha em uma classe pai como é mostrado no exemplo abaixo:
const CHILDREN = new WeakMap();
function registerChild(parent, child) {
let children = CHILDREN.get(parent);
if (children === undefined) {
children = [];
CHILDREN.set(parent, children);
}
children.push(child);
}
function getChildren(parent) {
return CHILDREN.get(parent);
}
function register() {
return function(value) {
registerChild(this, value);
return value;
}
}
class Child {}
class OtherChild {}
class Parent {
@register child1 = new Child();
@register child2 = new OtherChild();
}
let parent = new Parent();
getChildren(parent); // [Child, OtherChild]
Claro que você também pode usar uma lista de classes filhas interna da classe pai, por exemplo, para registrar injeção de dependências.
Classes
O último tipo de decorator é também um dos mais comuns, o decorator de classe. Ele segue uma versão simplificada da interface:
type ClassDecorator = (value: Function, context: {
kind: "class";
name: string | undefined;
addInitializer(initializer: () => void): void;
}) => Function | void;
A grande diferença além do kind
é que não temos métodos acessores e também não temos as propriedades para privada e estática.
O primeiro parâmetro vai ser sempre a classe que está sendo decorada e ele pode retornar um novo objeto do tipo callable, que é uma função, uma classe, um Proxy ou qualquer outra coisa que possa ser invocada.
Um exemplo, é estendermos o construtor de uma classe para que possamos incluir uma chamada para um console sempre que uma nova classe for invocada:
function debug (value, {kind, name}) {
if (kind === 'class') {
return class extends value {
constructor (...args) {
super(...args)
console.log(`construindo uma nova instancia de ${name} com os argumentos ${args.join(', ')}`)
}
}
}
}
E usarmos na nossa classe dessa forma:
@debug
class C {}
new C(1)
// construindo uma nova instancia de C com os argumentos 1
Essencialmente podemos fazer o mesmo da seguinte forma sem decorators:
class C {}
C = debug(C, {kind: 'class', name: 'C'}) ?? C
new C(1)
Auto accessors
Juntamente com a proposta de decorators, esse documento também propõe um outro elemento da sintaxe chamado auto accessors. Hoje podemos declarar acessores da seguinte forma:
class foo {
#privado = true
get getPrivado () { return this.#privado }
set setPrivado (val) { this.#privado = val }
}
Assim vamos ter uma propriedade getPrivado
e uma setPrivado
para poder acessar propriedades privadas dentro de classes, o que é bem útil quando temos que fazer algum tratamento de dados ou então setar algum tipo de informação que exija algum processamento prévio.
O que a proposta apresenta é a nova keyword accessor
, que vão fazer as seguintes operações:
- Criar uma propriedade privada de mesmo nome dentro da classe
- Criar um acessor
get
e um acessorset
para essa propriedade com o mesmo nome
No final teremos uma sintaxe como essa:
class C {
acessor x = 1
}
const c = new C()
c.x // 1
c.x = 2
c.x // 2
Isso é o mesmo do que fazermos:
class C {
#x = 1
get x() {
return this.#x
}
set (val) {
this.#x = val
}
}
Um detalhe é que também podemos ter acessores privados:
class C {
accessor #x = 2
}
Ao meu ver, a proposta apresenta osauto-accessors
como uma forma de contornar o problema de que não podemos setar um decorator automaticamente para umget
e umset
, como expliquei antes, então teríamos que chamar duas vezes a mesma função para, essencialmente, a mesma variável.
Os auto-accessors usam uma versão um pouco diferente da interface:
type ClassAutoAccessorDecorator = (
value: {
get: () => unknown;
set(value: unknown) => void;
},
context: {
kind: "accessor";
name: string | symbol;
access: { get(): unknown, set(value: unknown): void };
static: boolean;
private: boolean;
addInitializer(initializer: () => void): void;
}
) => {
get?: () => unknown;
set?: (value: unknown) => void;
init?: (initialValue: unknown) => unknown;
} | void;
Como você pode ver, o valor que recebemos no primeiro parâmetro é um objeto com os dois acessores da propriedade. O objeto de contexto recebe um kind
como accessor
, a propriedade access
com ambas as funções get
e set
e as demais propriedades que estamos vendo nas outras interfaces.
A questão é que, para o primeiro parâmetro, vamos receber o objeto com os dois acessores que estão definidos no protótipo da classe, ou seja, é o próprio objeto de acesso que a a classe vai ter. No caso de termos um acessor estático, vamos receber a própria classe.
Esse objeto existe para que o decorator possa criar um wrap em volta deles e retornar um novo get
e/ou um novo set
, essencialmente criando um proxy que intercepta as chamadas para qualquer um desses acessores. O que não é possível com propriedades de classe normalmente.
Adicionalmente, quando retornamos o objeto com as propriedades, também podemos retornar uma função init
que é uma função de inicialização que pode ser utilizada para mudar o valor inicial da variável privada que está setada na classe. Se você retornar o objeto sem qualquer um dos valores, seja get
, set
ou init
o valor original do acessor vai ser usado.
Criando um exemplo com nosso decorator de debug, podemos fazer uma extensão para ele trabalhar com os auto-accessors:
function debug (target, {kind, name}) {
if (kind === 'accessor') {
const {get, set} = target
return {
get() {
console.log(`get ${name}`)
return get.call(this)
},
set(val) {
console.log(`set ${name} para ${val}`)
return set.call(this, val)
},
init (initialValue) {
console.log(`iniciando ${name} com o valor ${initialValue}`)
return initialValue
}
}
}
}
Como você pode perceber, auto-accessors são um pouco mais longos de trabalhar porque você precisa retornar um objeto de funções, mas não é nada muito além do que já fizemos aqui na maioria dos outros casos.
Depois podemos usá-los da seguinte forma:
class C {
@debug accessor x = false
}
const c = new C()
// iniciando x com o valor false
c.x
// get x
c.x = true
// set x para true
c.x
// get x
Se quisermos fazer a mesma coisa sem os decorators, vamos usar uma mistura do que temos nas propriedades e nos acessores que já fizemos antes:
class C {
#x = inicializarX.call(this, 1);
get x() {
return this.#x;
}
set x(val) {
this.#x = val;
}
}
let { get: oldGet, set: oldSet } = Object.getOwnPropertyDescriptor(C.prototype, "x");
let {
get: newGet = oldGet,
set: newSet = oldSet,
init: initializeX = (initialValue) => initialValue
} = logged(
{ get: oldGet, set: oldSet },
{
kind: "accessor",
name: "x",
static: false,
private: false,
}
) ?? {};
Object.defineProperty(C.prototype, "x", { get: newGet, set: newSet });
addInitializer
e inicialização de contexto
O método addInitializer
que vimos em algumas das interfaces no objeto de contexto de todos os decorators, exceto o de classe, é um método que pode ser chamado para associar uma função de inicialização com a classe ou o elemento que estamos decorando.
Esse método pode ser usado para rodar qualquer código depois que o valor já foi setado permitindo que você possa finalizar a inicialização desse valor. Porém, a ordem de execução desses inicializadores depende do decorator que estamos usando:
- Para classes, os inicializadores rodam depois que a classe foi completamente definida, depois de todas as propriedades estáticas serem atribuídas
- Para elementos de classe (class elements), os inicializadores rodam durante a construção, mas antes da inicialização das propriedades da classe
- Para elementos estáticos, os inicializadores rodam também durante a inicialização da classe, antes dos campos estáticos serem definidos, mas depois que todos os elementos de classe foram definidos
Alguns exemplos que a proposta apresenta.
@customElement
Podemos usar o addInitializer
para poder decorar uma classe que vai registrar um novo webComponent no browser:
function customElement (name) {
return (value, { addInitializer }) => {
addInitializer(function() {
customElements.define(name, this)
})
}
}
@customElement('elemento')
class Elemento extends HTMLElement {
static get observedAttributes() {
return ['attr', 'att']
}
}
Neste exemplo, perceba que podemos "decorar" um decorator fazendo um wrap com uma outra função para que possamos passar parâmetros para ele, nesse caso estamos querendo passar a o nome do elemento para o decorator, então podemos criar uma função que recebe o nome e retorna uma outra função com a mesma assinatura do decorator.
@bound
Um decorator que é aplicado em um método de uma classe para poder modificar o seu this
para o this
daquela classe:
function bound (value, {name, addInitializer}) {
addInitializer(function () {
this[name] = this[name].bind(this)
})
}
class C {
message = 'oi!'
@bound
m() {
console.log(this.message)
}
}
const {m} = new C()
m() // oi!
Perceba que, em ambos os casos, estamos usandofunction()
dentro deaddInitializer
, isto porque queremos manter othis
daquele escopo como sendo o escopo do decorator, isso é mais evidente nesse exemplo, mas também vale para o@customElement
Acessores de contexto
Um objeto que não utilizamos aqui foi o objeto access
que vem de dentro dos contextos dos decorators.
Um exemplo muito útil é a criação de um contâiner de injeção de dependências. Que é uma ferramenta muito útil para poder criar automaticamente instâncias de classes dependentes para classes que levam essas dependências, dessa forma você não precisa passar todas as dependências como parâmetros.
Isso já é uma realidade com a biblioteca TSyringe feita pela Microsoft para demonstrar o poder dos decorators no TypeScript.
Essencialmente o que precisamos fazer é ter uma lista global de classes e suas dependências:
const INJETAVEIS = new WeakMap()
function initContainer() {
const injecoes = []
function injetavel (Class) {
INJETAVEIS.set(Class, injecoes)
}
function injetar (chave) {
return function aplicarDependencia (alvo, contexto) {
injecoes.push({ chave, set: context.access.set })
}
}
return { injetavel, injetar }
}
Essa função vai inicializar a nossa lista global de dependências para uma determinada classe, então o que precisamos fazer é anotar a classe que queremos automatizar com @injectable
e as dependências dessa classe com @inject
. Mas antes precisamos de um container que vai ser a nossa instância global que vai ler dessa lista:
class Container {
registro = new Map()
registrar (nome, valor) {
this.registro.set(nome, valor)
}
buscar (nome) {
return this.registry.get(nome)
}
criar (Classe) {
const instancia = new Classe()
for (const { chave, set } of INJETAVEIS.get(Classe) || []) {
set.call(instancia, this.buscar(chave))
}
return instancia
}
}
Aqui o que estamos fazendo é criando um container que vai registrar as dependências globais, ou seja, todas as classes que instanciamos uma vez, esse registro vai ter o nome que quisermos dar e também a instância da classe que criamos.
Quando definirmos uma nova classe através do container usando criar
, vamos passar o construtor da classe que queremos criar, depois, vamos buscar todas as classes injetáveis que batem com essa descrição na nossa lista global e vamos chamar o método set
para setar a uma nova propriedade na classe.
Quando chamamos set.call(instancia, this.buscar(chave))
estamos dizendo que queremos que a propriedade anotada chame seu acessor set
com o this
definido para a nova instância da classe que criamos, com o valor sendo a classe dependente que já instanciamos anteriormente.
Vamos dar um exemplo:
class Store {}
const { injetavel, injetar } = initContainer()
// A classe C é injetável e pode receber dependências externas
@injetavel
class C {
// Essa propriedade é a instancia guardada na chave
// nomeDaclasse que registramos no container
@injetar('nomeDaClasse') store
}
const container = new Container()
const store = new Store()
// Registrando a Store no container como uma dependência
container.register('nomeDaclasse', store)
const c = container.create(C)
c.store === store // true
Veja que estamos usando container.create(C)
para criar uma nova classe com as dependências já injetadas, mas isso não é totalmente necessário, como você pode ver nessa documentação do TSyringe e como já mostrei antes, podemos usar os decorators para substituir completamente o construtor da classe e rodar essa lógica automaticamente para todas as dependências de uma mesma classe.
Testando você mesmo
Se você quiser rodar qualquer um dos códigos que eu coloquei por aqui, mesmo antes da proposta estar completamente publicada e disponível, isso é possível através de transpiladores como o babel
.
Para isso, crie uma nova pasta em qualquer lugar e rode npm init -y
(lembrando que você precisa ter o Node e o NPM instalados), isso vai criar um novo arquivo package.json
, depois execute o comando npm i -D @babel/cli @babel/core @babel/plugin-proposal-decorators @babel/preset-env
.
Abra o arquivo package.json
e, na sessão scripts
, adicione um novo script transpile
:
{
"scripts": {
"transpile": "babel src -d dist"
}
}
Esse script vai pegar qualquer código .js
dentro da pasta src
e vai transpilar para um novo arquivo na pasta dist
.
Agora crie um novo arquivo chamado babel.config.json
com esse conteúdo:
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
]
],
"plugins": [
[
"@babel/plugin-proposal-decorators",
{
"version": "2022-03"
}
]
]
}
Escreva um arquivo de teste com qualquer um dos exemplos, ou então crie seu próprio, como esse:
@annotation
class MyClass {
@property accessor bool = false
}
function annotation(...params) {
console.log(params)
}
function property(target, name) {
console.log(target, name)
return {
get() {
console.log('get')
return target.get.call(this)
},
set(val) {
console.log('set', val)
return target.set.call(this, val)
}
}
}
function debug(target, { kind, name }) {
if (kind === 'accessor') {
const { get, set } = target
return {
get() {
console.log(`get ${name}`)
return get.call(this)
},
set(val) {
console.log(`set ${name} para ${val}`)
return set.call(this, val)
},
init(initialValue) {
console.log(`iniciando ${name} com o valor ${initialValue}`)
return initialValue
}
}
}
}
const a = new MyClass()
console.log(a.bool)
a.bool = true
console.log(a.bool)
Rode npm run transpile
e depois node dist/<arquivo>.js
e veja a mágica acontecer!
Conclusão
Os decorators são um padrão de projeto incrível e possuem um potencial imenso para serem uma das funcionalidades mais interessantes da linguagem e permitirem que façamos muito mais coisas de forma muito simples.
Eu, particularmente, vejo uma grande adoção por ferramentas de monitoramento como NewRelic, NSolid e Datadog para Node.js e até mesmo o JavaScript no browser!
Comenta ai embaixo o que você achou dessa proposta e me marca lá no Twitter pra eu saber sua opinião!