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
importeexport - 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:
{ "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:
{ "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
typesdeve vir sempre primeiro do que odefaultem 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 funcionaf({ produce: (n) => n, consume: (x) => x.toLowerCase()})
// Erro antes, agora funcionaf({ produce: function () { return 'hello' }, consume: (x) => x.toLowerCase()})
// Erro antes, agora funcionaf({ 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> = makeBoxAgora é 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]>
// nevertype 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 : neverVariancia 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> = () => Ttype Setter<T> = (value: T) => voidNesse 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> = () => Ttype Setter<in T> = (value: T) => voidEntã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
inououtjá 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!