O que há de novo no TypeScript 4.7
O TypeScript 4.7 chegou e, como não podemos deixar de lado, vamos passar pelas principais partes que foram anunciadas pelo time de desenvolvimento.
ESModules com suporte no Node.js
Já faz um tempo que o Node.js está com suporte para ESM (inclusive temos artigos aqui no blog sobre isso), porém o TypeScript não estava exatamente acompanhando o que estava acontecendo, principalmente porque foi uma das mudanças mais críticas que aconteceram no ecossistema, já que todo o Node.js foi construído no modelo CommonJS (CJS).
Interoperabilidade entre os dois modos de importação não é só complexo, mas também traz diversos problemas e novos desafios, especialmente nas funcionalidades mais antigas. Apesar do suporte ao ESM já estar no TypeScript como experimental desde o 4.5, ainda não era o momento de lançar ele como funcionalidade completa.
No entanto, a versão 4.7 do TS já trás o suporte mais recente (Node 16) ao ESM através da opção module
no tsconfig.json
.
{
"compilerOptions": {
"module": "node16"
}
}
Suporte a type
e novas extensões
Como já falamos em outros artigos aqui no blog, basicamente, para usarmos ESM em um módulo Node.js, basta que ou chamemos o arquivo pela extensão .mjs
ou então incluindo a chave type
no package.json
com o valor module
.
Relembrando algumas das regras quando usamos ESM:
- Podemos usar as keywords
import
eexport
- Temos o tão útil top-level
await
, então não precisamos de uma funçãoasync
- Precisamos usar o nome completo dos arquivos incluindo a extensão nas importações
- Algumas outras regras menores
A mudança para o lado do TS foi menor, porque já usávamos o "estilo ESM" para importar módulos, mas isso era nativo, quando compilávamos o código para JS no final, a gente acabava com um monte de require
do mesmo jeito.
O que acontece agora é que o TS vai começar a tratar arquivos .ts
(e suas variações como .tsx
) da mesma forma que o Node trataria os arquivos JS, ou seja, o compilador vai procurar o primeiro package.json
para determinar se aquele arquivo está em um módulo ou não, se sim, os import
e export
vão ser deixados no código final, e algumas coisas vão mudar na importação de módulos no geral.
O exemplo clássico é o uso da extensão, então um código comum como esse, que funcionaria normalmente com CJS:
export function foo() {}
import { foo } from './foo'
Não funcionaria no ESM porque ./foo
não tem a extensão completa do arquivo, o import
deveria ser trocado para essa outra forma para poder funcionar em ambos os meios de resolução:
import { foo } from './foo.ts'
Além disso, da mesma forma que temos as extensões .mjs
e .cjs
para interpretar arquivos JS que são ESM ou CJS, temos agora as extensões .mts
e .cts
, que produzirão os arquivos de definição .d.mts
e .d.cts
, além de arquivos .mjs
ou .cjs
correspondentes de acordo com o arquivo de entrada.
Todas as demais regras de ESM vs CJS continuam sendo aplicadas normalmente.
Exports, Imports e Auto-referência no package.json
Desde que começamos a ter ESM no Node.js, temos um novo campo no package.json
que permite um pacote definir diferentes pacotes quando ele é importado via ESM ou CJS, esse campo é o exports
:
// package.json
{
"name": "my-package",
"type": "module",
"exports": {
".": {
// entrypoint para ESM
"import": "./esm/index.js",
// entrypoint para cjs
"require": "./commonjs/index.cjs"
}
},
// Fallback para outras versões
"main": "./commonjs/index.cjs"
}
A forma como o TS suporta esses novos campos basicamente se resume a como ele funciona hoje. A ideia é que quando um tipo é inferido a partir de um pacote, o TS vai procurar o campo main
dentro do package.json
daquele pacote e dai procurar o arquivo .d.ts
correspondente a não ser que o pacote especifique uma chave types
.
Como é de se esperar, no novo modelo, o TS vai buscar o campo import
dentro da chave export
de um package.json
se houver, ou um campo require
se o arquivo for um arquivo CJS. Você pode definir para cada um deles também, o local onde os tipos estão localizados e onde o Node.js deveria procurar:
// package.json
{
"name": "my-package",
"type": "module",
"exports": {
".": {
"import": {
// Onde o TS vai procurar tipos
"types": "./types/esm/index.d.ts",
// Onde o Node.js vai procurar o pacote
"default": "./esm/index.js"
},
"require": {
"types": "./types/commonjs/index.d.cts",
"default": "./commonjs/index.cjs"
}
}
},
// Fall-back pra outras versões do TS
"types": "./types/index.d.ts",
"main": "./commonjs/index.cjs"
}
Algo que é digno de nota:
A chave
types
deve vir sempre primeiro do que odefault
em um objetoexports
Analise de fluxo para elementos de um objeto
Uma melhoria na detecção de tipos em chaves de objeto foi feita no TS 4.7, antigamente um código como esse:
const key = Symbol()
const numberOrString = Math.random() < 0.5 ? 42 : 'hello'
const obj = {
[key]: numberOrString
}
if (typeof obj[key] === 'string') {
let str = obj[key].toUpperCase()
}
Não iria encontrar o tipo da chave obj[key]
automaticamente e continuaria informando que o tipo ainda é string | number
, hoje é possível detectar que esse tipo agora já é uma string
por padrão.
A mesma melhoria granular foi aplicada em parâmetros que são objetos de funções como este exemplo:
declare function f<T>(arg: { produce: (n: string) => T; consume: (x: T) => void }): void
f({
produce: () => 'hello',
consume: (x) => x.toLowerCase()
})
f({
produce: (n: string) => n,
consume: (x) => x.toLowerCase()
})
// Erro antes, agora funciona
f({
produce: (n) => n,
consume: (x) => x.toLowerCase()
})
// Erro antes, agora funciona
f({
produce: function () {
return 'hello'
},
consume: (x) => x.toLowerCase()
})
// Erro antes, agora funciona
f({
produce() {
return 'hello'
},
consume: (x) => x.toLowerCase()
})
Ou seja, o TS ficou mais inteligente para encontrar tipos de funções e seus retornos dentro de objetos que são, na verdade, parâmetros de outra função.
Instantiation Expressions
Quando usamos generics em TS, na maioria das vezes as funções ficam extremamete genéricas, como é de se esperar. Porém se a gente quiser especializar elas um pouco, sempre temos que criar um wrapper, por exemplo, essa função retorna um tipo Box
, que é genérico:
interface Box<T> {
value: T
}
function makeBox<T>(value: T) {
return { value }
}
Se a gente quiser criar uma variação dessa função (essencialmente um alias) onde T é explicitamente um tipo Hammer
ou Wrench
a gente teria ou que criar uma nova função que recebe Hammer
como parâmetro e retornar a chamada de makeBox
com esse parâmetro, dessa forma o TS iria inferir o tipo:
function makeHammerBox(hammer: Hammer) {
return makeBox(hammer)
}
Ou fazer um overload de tipos:
const makeWrenchBox: (wrench: Wrench) => Box<Wrench> = makeBox
Agora é possível associar o tipo direto a uma variável, ou seja, podemos trocar o generic direto na associação da variável com o tipo que queremos:
const makeHammerBox = makeBox<Hammer>
Teria o mesmo efeito dos anteriores. E isso é especialmente útil quando temos tipos genéricos nativos, como o Map
, Set
e Array
:
const MapComum = new Map(1, 2) // Assumiria um Map<number, number>
const ErrorMap = Map<string, Error>
const errorMap = new ErrorMap() // tipo é Map<string, Error>
extends
disponível para tipos infer
Recentemente eu postei aqui no blog um artigo sobre o que é o infer
no TS. Em suma, ele permite que a gente extraia o tipo de uma variável quando estamos utilizando em uma clausula extends
, por exemplo, quando queremos pegar o primeiro elemento de uma tupla somente se ele for uma string:
type FirstIfString<T> = T extends [infer S, ...unknown[]] ? (S extends string ? S : never) : never
// "hello"
type B = FirstIfString<['hello', number, number]>
// "hello" | "world"
type C = FirstIfString<['hello' | 'world', boolean]>
// never
type D = FirstIfString<[boolean, number, string]>
Agora, ter que fazer dois ternários para esse tipo de verificação é um pouco chato, então para simplificar a ideia, podemos agora usar extends
juntamente com o infer
e o tipo ficaria assim:
type FirstIfString<T> =
T extends [infer S extends string, ...unknown[]]
? S
: never
Variancia de tipos explícita
Agora é possível anotar os tipos de entrada ou saída de uma função com um indicador de variância. A explicação inteira é bastante complexa e cobre um certo grupo de usos que são até que bem avançados.
Em essencia, a ideia é tentar discernir quando um tipo genérico T
, por exemplo, é diferente em invocações distintas, por exemplo:
interface Animal {
animalStuff: any
}
interface Dog extends Animal {
dogStuff: any
}
// ...
type Getter<T> = () => T
type Setter<T> = (value: T) => void
Nesse caso, se a gente tiver duas instâncias do tipo Getter
, tentar descobrir se o tipo que a gente mandou para ele ou se o tipo T é indiferenciável um do outro é bastante complicado. Principalmente porque um tipo é extensão de outro, isso significa que de um lado, todos os Dog
são Animal
mas nem todo Animal
é um Dog
, então a variancia Dog -> Animal
é verdadeira enquanto Animal -> Dog
não é.
Agora podemos definir se o tipo é um tipo de entrada ou saída com a anotação in
e out
:
interface Animal {
animalStuff: any
}
interface Dog extends Animal {
dogStuff: any
}
// ...
type Getter<out T> = () => T
type Setter<in T> = (value: T) => void
Então se tivermos um tipo de saída no mesmo escopo, o TS pode ser bem mais rápido para identificar o tipo, ainda mais em tipos circulares.
Mas atenção, não é recomendado que você saia por ai anotando todas as suas funções e todos os seus parâmetros com
in
ouout
já que o TS faz um ótimo trabalho com isso.
Alterações menores:
- Organização de imports baseada em grupos
- Go to Source definition
- Resolution mode pode ser customizado
- Intellisense para completude de métodos em objetos
Conclusão
É isso ai! Se você quiser saber mais sobre as novidades não só do TS mas também do Node.js não deixe de assinar a minha newsletter para receber as melhores notícias e os melhores conteúdos curados de tecnologia direto no seu email!