Mais uma vez estamos aqui para as novidades do TypeScript! Dessa vez vamos falar de uma que não é só uma novidade do TS mas que também vai estar chegando para o JavaScript logo mais!
Essa é a funcionalidade chamada explicit resource management, ou gerenciamento explícito de recursos. Que é mais definida como uma nova keyword: using
Em outras linguagens como o C#, o using
já é uma keyword bem famosa, e ela existe em outras formas, como o try-with-resources
do Java ou o with
do Python, a ideia dessa proposta é poder atrelar o ciclo de vida de um recurso a ele mesmo, sem que a gente precise chamar outras funções como um finally
para poder se desfazer desse recurso.
Gerenciamento de recursos
Quando estamos falando de programação de mais baixo nível como C ou C++, gerenciamento de recursos é algo essencial, mas quando estamos em linguagens de mais alto nível, esse tipo de funcionalidade acaba se perdendo bastante já que o compilador ou o engine vai tomar conta disso pra gente.
Mas as vezes é necessário fazer uma "limpeza" em alguns recursos que estamos usando depois de criar ou acabar de utilizar esse recurso. O exemplo mais clássico disso tudo é fechar uma conexão de rede ou de banco de dados. Se tivermos algo como:
import connection from 'seu-banco'
export async function fazerQuery (query: string) {
const db = connection.start()
const result = await db.query(query)
connection.close()
return result
}
Se a gente, por algum motivo, precisar fazer um early return, vamos ter que duplicar o código que está liberando o nosso recurso com o connection.close()
:
import connection from 'seu-banco'
export async function fazerQuery (query: string) {
const db = connection.start()
const result = await db.query(query)
if (!result) {
connection.close()
return
}
connection.close()
return result
}
Mas não garantimos se um erro acontecer, então a gente precisa de um try/catch
, ai é mais fácil jogar tudo isso em um finally
para ficar mais simples de ler, certo?
import connection from 'seu-banco'
export async function fazerQuery (query: string) {
try {
const db = connection.start()
const result = await db.query(query)
if (!result) return
return result
} catch (err) {
console.error(err)
} finally {
connection.close()
}
}
Enquanto isso é uma solução interessante, a gente escreveu uma quantidade considerável de código para poder fechar uma conexão... E é pra isso que a ideia do gerenciamento explícito existe, para tratar esses casos como algo primário.
Symbol.dispose
Tudo gira em torno de uma propriedade do arquivo chamada Symbol.dispose
, que é um Símbolo interno de todas as classes.
No caso, vamos imaginar que essa seja a nossa classe de conexão (e que exista uma factory em algum lugar que nos retorna a instância que usamos acima):
export class Connection {
constructor (options: ConnectionOptions) {
// ...
}
start () { }
close () { }
}
Para podermos transformar a classe de conexão em uma classe que pode ser recolhida, vamos implementar uma nova propriedade:
export class Connection {
constructor (options: ConnectionOptions) {
// ...
}
start () { }
close () { }
[Symbol.dispose]() {
this.close()
}
}
Se você quiser mais conveniência, o TS já tem uma interface global chamada Disposable
que você pode implementar para deixar o código mais coeso:
export class Connection implements Disposable {
constructor (options: ConnectionOptions) {
// ...
}
start () { }
close () { }
[Symbol.dispose]() {
this.close()
}
}
E ai agora podemos simplesmente chamar essa funcionalidade para poder limpar o nosso arquivo:
import connection from 'seu-banco'
export async function fazerQuery (query: string) {
try {
const db = connection.start()
const result = await db.query(query)
if (!result) return
return result
} catch (err) {
console.error(err)
} finally {
connection[Symbol.dispose]()
}
}
Não ajudou muito não é? A gente só moveu de um lado pro outro, apesar de agora termos um lugar específico para chamar toda a lógica, ainda sim é mais fácil do que ficar chamando os métodos específicos.
Using
Mas, se a gente quiser passar tudo para um lugar só, podemos tirar vantagem da nova keyword using
, ela funciona como se fosse um let
ou const
mas ao invés de só declarar a variável, ela instrui o engine a chamar Symbol.dispose
no final do escopo daquela função, então nossa função anterior pode ser reescrita assim:
import connection from 'seu-banco'
export async function fazerQuery (query: string) {
try {
using db = connection.start()
const result = await db.query(query)
if (!result) return
return result
} catch (err) {
console.error(err)
}
}
Agora não temos mais a lógica de gerenciamento de recursos dentro do nosso app, o que torna muito mais fácil gerenciar essas conexões e abstrair a funcionalidade de usuários, especialmente para quem cria libs!
Se você quiser aprender a usar essa funcionalidade, aproveita e dá uma olhada no meu treinamento completo de TypeScript!
Disposals funcionam como uma stack, então eles vão ser chamados da última criação para a primeira. Como esse exemplo da documentação mostra bem:
function loggy(id: string): Disposable {
console.log(`Creating ${id}`);
return {
[Symbol.dispose]() {
console.log(`Disposing ${id}`);
}
}
}
function func() {
using a = loggy("a");
using b = loggy("b");
{
using c = loggy("c");
using d = loggy("d");
}
using e = loggy("e");
return;
// Unreachable.
using f = loggy("f");
}
func();
// Creating a
// Creating b
// Creating c
// Creating d
// Disposing d
// Disposing c
// Creating e
// Disposing e
// Disposing b
// Disposing a
Veja que eles são criados na ordem de A até D, mas destruídos de D até A. Inclusive, se você criar um escopo no meio da função, como é o caso de C e D, eles vão ser destruídos primeiro ao saírem do escopo.
Assincronismo com Symbol.asyncDispose
Além de termos a versão síncrona, também temos a versão assíncrona do dispose. Ela se comporta da mesma forma, porém é uma função assíncrona e precisa ser usada com await using
ao invés de using
.
async function wait () {
await new Promise(resolve => setTimeout(resolve, 500))
}
function loggy(id: string): AsyncDisposable {
console.log(`Creating ${id}`);
return {
async [Symbol.asyncDispose]() {
console.log(`Disposing ${id} async`);
await wait()
}
}
}
function func() {
await using a = loggy("a");
await using b = loggy("b");
{
await using c = loggy("c");
await using d = loggy("d");
}
await using e = loggy("e");
return;
// Unreachable.
await using f = loggy("f");
}
func();
// Creating a
// Creating b
// Creating c
// Creating d
// Disposing d async
// Disposing c async
// Creating e
// Disposing e async
// Disposing b async
// Disposing a async
Tratamento de erros
O que acontece se a nossa função de dispose tiver um erro? Ou se tivermos um erro durante a função e também na hora de destruir a função? Neste caso temos um novo tipo de erro que é estendido de Error
, o SuppressedError
.
Erros do tipo SuppressedError
tem uma propriedade suppressed
que contém o último erro que foi gerado e uma outra propriedade error
para o erro mais recente.
Por exemplo, se tivermos um código como esse:
class ErrorA extends Error {
name = "ErrorA";
}
class ErrorB extends Error {
name = "ErrorB";
}
function foo (id: string) {
return {
[Symbol.dispose]() {
throw new ErrorA(`Erro do id ${id}`)
}
}
}
function bar () {
using f = foo("1")
throw new ErrorB("Erro!")
}
try {
bar()
} catch (e: any) {
console.log(e.name, e.message) // SuppressedError An error was suppressed during disposal
console.log(e.error.name) // ErrorA
console.log(e.error.message) // Erro do id 1
console.log(e.suppressed.name) // ErrorB
console.log(e.suppressed.message) // Erro!
}
Então basicamente o erro mais recente vai ser o erro que foi gerado dentro do symbol, enquando o erro suprimido é o erro que foi gerado dentro da função antes do disposal ser chamado.
DisposableStacks
Como você pode ter percebido, Symbol.dispose
e a sua versão assíncrona podem ser ótimas soluções para códigos mais complexos, porque já estamos usando uma classe e podemos implementar a funcionalidade, mas quando temos um código simples como esse pode parecer meio demais ter que fazer toda essa lógica.
No nosso caso a gente só quer lembrar de chamar o close
no final da execução, nada mais. Para isso temos duas novas funcionalidades no TS que são oDisposableStack
e AsyncDisposableStack
, que basicamente funcionam como ferramentas para poder executar esses symbols manualmente no final da função.
Então se a gente esquecer a nossa classe e assumir que ela não tem nenhum tipo de lógica para recuperar o recurso, voltando a nossa função original, poderíamos ter escrito ela assim:
import connection from 'seu-banco'
export async function fazerQuery (query: string) {
try {
const db = connection.start()
using cleanup = new DisposableStack()
cleanup.defer(() => connection.close())
const result = await db.query(query)
if (!result) return
return result
} catch (err) {
console.error(err)
}
}
Perceba que estamos usando uma funcionalidade chamada defer
que é muito comum em Golang. O que ela faz é justamente empurrar a execução desse bloco para o final do escopo atual.
Imagine que ele é uma classe implementada assim (apenas ilustrativo):
export class DisposableStack implements Disposable {
#stack = []
defer (fn: (...args: any) => void) {
this.#stack.push(fn)
}
[Symbol.dispose]() {
for (const fn of this.#stack) {
fn()
}
}
}
A ideia é que você pode definir uma stack e chamar o método defer
várias vezes para adicionar uma ou mais funções na stack de reciclagem.
Conclusão
Para usar o using nas versões mais novas do TS você precisa mudar algumas opções no compilerOptions
do tsconfig
, essas incluem as opções que vão mudar o alvo da compilação para a versão 2022 e adicionar os polyfills necessários, ficando algo assim:
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.disposable", "dom"]
}
}
Você pode ler mais sobre essa nova feature lá no blog do TS e também aprender a usar tudo certinho lá no meu treinamento da Formação TS!.