O que há de novo no TypeScript 4.7

typescript7 min

porLucas Santos

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 e export
  • Temos o tão útil top-level await, então não precisamos de uma função async
  • 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 o default em um objeto exports

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 ou out já que o TS faz um ótimo trabalho com isso.

Alterações menores:#

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!