Explorando o Sistema de Tipos
O TypeScript Language Service
O TypeScript Language Service, também conhecido como tsserver, oferece vários recursos como relatórios de erros, diagnósticos, compilação ao salvar, renomeação, ir para definição, listas de conclusão, ajuda de assinatura e muito mais. É usado principalmente por ambientes de desenvolvimento integrados (IDEs) para fornecer suporte IntelliSense. Ele se integra perfeitamente com o Visual Studio Code e é utilizado por ferramentas como Conquer of Completion (Coc).
Os desenvolvedores podem aproveitar uma API dedicada e criar seus próprios plugins personalizados de language service para aprimorar a experiência de edição do TypeScript. Isso pode ser particularmente útil para implementar recursos especiais de linting ou habilitar auto-completar para uma linguagem de template personalizada.
Um exemplo de um plugin personalizado real é o “typescript-styled-plugin”, que fornece relatório de erros de sintaxe e suporte IntelliSense para propriedades CSS em styled components.
Para mais informações e guias de início rápido, você pode consultar a Wiki oficial do TypeScript no GitHub: https://github.com/microsoft/TypeScript/wiki/
Tipagem Estrutural
O TypeScript é baseado em um sistema de tipos estrutural. Isso significa que a compatibilidade e equivalência de tipos são determinadas pela estrutura ou definição real do tipo, em vez de seu nome ou local de declaração, como em sistemas de tipos nominativos como C# ou C.
O sistema de tipos estrutural do TypeScript foi projetado com base em como o sistema de tipagem dinâmica duck typing do JavaScript funciona durante o tempo de execução.
O exemplo a seguir é um código TypeScript válido. Como você pode observar, “X” e “Y” têm o mesmo membro “a”, mesmo que tenham nomes de declaração diferentes. Os tipos são determinados por suas estruturas e, neste caso, como as estruturas são as mesmas, eles são compatíveis e válidos.
type X = { a: string;};type Y = { a: string;};const x: X = { a: 'a' };const y: Y = x; // VálidoRegras Fundamentais de Comparação do TypeScript
O processo de comparação do TypeScript é recursivo e executado em tipos aninhados em qualquer nível.
Um tipo “X” é compatível com “Y” se “Y” tiver pelo menos os mesmos membros que “X”.
type X = { a: string;};const y = { a: 'A', b: 'B' }; // Válido, pois tem pelo menos os mesmos membros que Xconst r: X = y;Os parâmetros de função são comparados por tipos, não por seus nomes:
type X = (a: number) => void;type Y = (a: number) => void;let x: X = (j: number) => undefined;let y: Y = (k: number) => undefined;y = x; // Válidox = y; // VálidoOs tipos de retorno de função devem ser os mesmos:
type X = (a: number) => undefined;type Y = (a: number) => number;let x: X = (a: number) => undefined;let y: Y = (a: number) => 1;y = x; // Inválidox = y; // InválidoO tipo de retorno de uma função de origem deve ser um subtipo do tipo de retorno de uma função de destino:
let x = () => ({ a: 'A' });let y = () => ({ a: 'A', b: 'B' });x = y; // Válidoy = x; // Inválido, membro b está faltandoDescartar parâmetros de função é permitido, pois é uma prática comum em JavaScript, por exemplo, usando “Array.prototype.map()“:
[1, 2, 3].map((element, _index, _array) => element + 'x');Portanto, as seguintes declarações de tipo são completamente válidas:
type X = (a: number) => undefined;type Y = (a: number, b: number) => undefined;let x: X = (a: number) => undefined;let y: Y = (a: number) => undefined; // Parâmetro b faltandoy = x; // VálidoQuaisquer parâmetros opcionais adicionais do tipo de origem são válidos:
type X = (a: number, b?: number, c?: number) => undefined;type Y = (a: number) => undefined;let x: X = a => undefined;let y: Y = a => undefined;y = x; // Válidox = y; // VálidoQuaisquer parâmetros opcionais do tipo de destino sem parâmetros correspondentes no tipo de origem são válidos e não são um erro:
type X = (a: number) => undefined;type Y = (a: number, b?: number) => undefined;let x: X = a => undefined;let y: Y = a => undefined;y = x; // Válidox = y; // VálidoO parâmetro rest é tratado como uma série infinita de parâmetros opcionais:
type X = (a: number, ...rest: number[]) => undefined;let x: X = a => undefined; // válidoFunções com sobrecargas são válidas se a assinatura de sobrecarga for compatível com sua assinatura de implementação:
function x(a: string): void;function x(a: string, b: number): void;function x(a: string, b?: number): void { console.log(a, b);}x('a'); // Válidox('a', 1); // Válido
function y(a: string): void; // Inválido, não compatível com a assinatura de implementaçãofunction y(a: string, b: number): void;function y(a: string, b: number): void { console.log(a, b);}y('a');y('a', 1);A comparação de parâmetros de função é bem-sucedida se os parâmetros de origem e destino forem atribuíveis a supertipos ou subtipos (bivariância).
// Supertipoclass X { a: string; constructor(value: string) { this.a = value; }}// Subtipoclass Y extends X {}// Subtipoclass Z extends X {}
type GetA = (x: X) => string;const getA: GetA = x => x.a;
// Bivariância aceita supertiposconsole.log(getA(new X('x'))); // Válidoconsole.log(getA(new Y('Y'))); // Válidoconsole.log(getA(new Z('z'))); // VálidoEnums são comparáveis e válidos com números e vice-versa, mas comparar valores de Enum de diferentes tipos de Enum é inválido.
enum X { A, B,}enum Y { A, B, C,}const xa: number = X.A; // Válidoconst ya: Y = 0; // VálidoX.A === Y.A; // InválidoInstâncias de uma classe estão sujeitas a uma verificação de compatibilidade para seus membros privados e protegidos:
class X { public a: string; constructor(value: string) { this.a = value; }}
class Y { private a: string; constructor(value: string) { this.a = value; }}
let x: X = new Y('y'); // InválidoA verificação de comparação não leva em consideração a diferente hierarquia de herança, por exemplo:
class X { public a: string; constructor(value: string) { this.a = value; }}class Y extends X { public a: string; constructor(value: string) { super(value); this.a = value; }}class Z { public a: string; constructor(value: string) { this.a = value; }}let x: X = new X('x');let y: Y = new Y('y');let z: Z = new Z('z');x === y; // Válidox === z; // Válido mesmo que z seja de uma hierarquia de herança diferenteGenerics são comparados usando suas estruturas com base no tipo resultante após aplicar o parâmetro genérico, apenas o resultado final é comparado como um tipo não-genérico.
interface X<T> { a: T;}let x: X<number> = { a: 1 };let y: X<string> = { a: 'a' };x === y; // Inválido pois o argumento de tipo é usado na estrutura finalinterface X<T> {}const x: X<number> = 1;const y: X<string> = 'a';x === y; // Válido pois o argumento de tipo não é usado na estrutura finalQuando os generics não têm seu argumento de tipo especificado, todos os argumentos não especificados são tratados como tipos com “any”:
type X = <T>(x: T) => T;type Y = <K>(y: K) => K;let x: X = x => x;let y: Y = y => y;x = y; // VálidoLembre-se:
let a: number = 1;let b: number = 2;a = b; // Válido, tudo é atribuível a si mesmo
let c: any;c = 1; // Válido, todos os tipos são atribuíveis a any
let d: unknown;d = 1; // Válido, todos os tipos são atribuíveis a unknown
let e: unknown;let e1: unknown = e; // Válido, unknown é atribuível apenas a si mesmo e anylet e2: any = e; // Válidolet e3: number = e; // Inválido
let f: never;f = 1; // Inválido, nada é atribuível a never
let g: void;let g1: any;g = 1; // Inválido, void não é atribuível a ou de nada exceto anyg = g1; // VálidoPor favor, observe que quando “strictNullChecks” está habilitado, “null” e “undefined” são tratados de forma semelhante a “void”; caso contrário, eles são semelhantes a “never”.
Tipos como Conjuntos
No TypeScript, um tipo é um conjunto de valores possíveis. Este conjunto também é chamado de domínio do tipo. Cada valor de um tipo pode ser visto como um elemento em um conjunto. Um tipo estabelece as restrições que cada elemento no conjunto deve satisfazer para ser considerado um membro desse conjunto. A principal tarefa do TypeScript é verificar se um conjunto é um subconjunto de outro.
O TypeScript suporta vários tipos de conjuntos:
| Termo de conjunto | TypeScript | Notas |
|---|---|---|
| Conjunto vazio | never | ”never” não contém nada além de si mesmo |
| Conjunto de elemento único | undefined / null / tipo literal | |
| Conjunto finito | boolean / union | |
| Conjunto infinito | string / number / object | |
| Conjunto universal | any / unknown | Cada elemento é um membro de “any” e todo conjunto é um subconjunto dele / “unknown” é uma contraparte type-safe de “any” |
Aqui estão alguns exemplos:
| TypeScript | Termo de conjunto | Exemplo |
|---|---|---|
| never | ∅ (conjunto vazio) | const x: never = ‘x’; // Erro: Type ‘string’ is not assignable to type ‘never’ |
| Tipo literal | Conjunto de elemento único | type X = ‘X’; |
| type Y = 7; | ||
| Valor atribuível a T | Valor ∈ T (membro de) | type XY = ‘X’ | ‘Y’; |
| const x: XY = ‘X’; | ||
| T1 atribuível a T2 | T1 ⊆ T2 (subconjunto de) | type XY = ‘X’ | ‘Y’; |
| const x: XY = ‘X’; | ||
| const j: XY = ‘J’; // Type ‘“J”’ is not assignable to type ‘XY’. | ||
| T1 extends T2 | T1 ⊆ T2 (subconjunto de) | type X = ‘X’ extends string ? true : false; |
| T1 | T2 | T1 ∪ T2 (união) | type XY = ‘X’ | ‘Y’; |
| type JK = 1 | 2; | ||
| T1 & T2 | T1 ∩ T2 (interseção) | type X = { a: string } |
| type Y = { b: string } | ||
| type XY = X & Y | ||
| const x: XY = { a: ‘a’, b: ‘b’ } | ||
| unknown | Conjunto universal | const x: unknown = 1 |
Uma união, (T1 | T2) cria um conjunto mais amplo (ambos):
type X = { a: string;};type Y = { b: string;};type XY = X | Y;const r: XY = { a: 'a', b: 'x' }; // VálidoUma interseção, (T1 & T2) cria um conjunto mais restrito (apenas compartilhados):
type X = { a: string;};type Y = { a: string; b: string;};type XY = X & Y;const r: XY = { a: 'a' }; // Inválidoconst j: XY = { a: 'a', b: 'b' }; // VálidoA palavra-chave extends pode ser considerada como “subconjunto de” neste contexto. Ela define uma restrição para um tipo. O extends usado com um generic, trata o generic como um conjunto infinito e o restringirá a um tipo mais específico.
Por favor, observe que extends não tem nada a ver com hierarquia no sentido de POO (não há esse conceito no TypeScript).
O TypeScript trabalha com conjuntos e não tem uma hierarquia estrita, na verdade, como no exemplo abaixo, dois tipos podem se sobrepor sem que nenhum seja um subtipo do outro tipo (o TypeScript considera a estrutura, a forma dos objetos).
interface X { a: string;}interface Y extends X { b: string;}interface Z extends Y { c: string;}const z: Z = { a: 'a', b: 'b', c: 'c' };interface X1 { a: string;}interface Y1 { a: string; b: string;}interface Z1 { a: string; b: string; c: string;}const z1: Z1 = { a: 'a', b: 'b', c: 'c' };
const r: Z1 = z; // VálidoAtribuir um tipo: Declarações de Tipo e Asserções de Tipo
Um tipo pode ser atribuído de diferentes maneiras no TypeScript:
Declaração de Tipo
No exemplo a seguir, usamos x: X (”: Type”) para declarar um tipo para a variável x.
type X = { a: string;};
// Declaração de tipoconst x: X = { a: 'a',};Se a variável não estiver no formato especificado, o TypeScript reportará um erro. Por exemplo:
type X = { a: string;};
const x: X = { a: 'a', b: 'b', // Erro: Object literal may only specify known properties};Asserção de Tipo
É possível adicionar uma asserção usando a palavra-chave as. Isso informa ao compilador que o desenvolvedor tem mais informações sobre um tipo e silencia quaisquer erros que possam ocorrer.
Por exemplo:
type X = { a: string;};const x = { a: 'a', b: 'b',} as X;No exemplo acima, o objeto x é afirmado como tendo o tipo X usando a palavra-chave as. Isso informa ao compilador TypeScript que o objeto está em conformidade com o tipo especificado, mesmo que tenha uma propriedade adicional b não presente na definição de tipo.
Asserções de tipo são úteis em situações onde um tipo mais específico precisa ser especificado, especialmente ao trabalhar com o DOM. Por exemplo:
const myInput = document.getElementById('my_input') as HTMLInputElement;Aqui, a asserção de tipo as HTMLInputElement é usada para informar ao TypeScript que o resultado de getElementById deve ser tratado como um HTMLInputElement. Asserções de tipo também podem ser usadas para remapear chaves, como mostrado no exemplo abaixo com template literals:
type J<Type> = { [Property in keyof Type as `prefix_${string & Property}`]: () => Type[Property];};type X = { a: string; b: number;};type Y = J<X>;Neste exemplo, o tipo J<Type> usa um tipo mapeado com um template literal para remapear as chaves de Type. Ele cria novas propriedades com um “prefix_” adicionado a cada chave, e seus valores correspondentes são funções que retornam os valores das propriedades originais.
Vale a pena notar que ao usar uma asserção de tipo, o TypeScript não executará verificação de propriedades em excesso. Portanto, geralmente é preferível usar uma Declaração de Tipo quando a estrutura do objeto é conhecida antecipadamente.
Declarações Ambientes
Declarações ambientes são arquivos que descrevem tipos para código JavaScript, eles têm um formato de nome de arquivo como .d.ts.. Eles geralmente são importados e usados para anotar bibliotecas JavaScript existentes ou para adicionar tipos a arquivos JS existentes em seu projeto.
Muitos tipos de bibliotecas comuns podem ser encontrados em: https://github.com/DefinitelyTyped/DefinitelyTyped/
e podem ser instalados usando:
npm install --save-dev @types/library-namePara suas Declarações Ambientes definidas, você pode importar usando a referência “triple-slash”:
/// <reference path="./library-types.d.ts" />Você pode usar Declarações Ambientes mesmo dentro de arquivos JavaScript usando // @ts-check.
A palavra-chave declare habilita definições de tipo para código JavaScript existente sem importá-lo, servindo como um espaço reservado para tipos de outro arquivo ou globalmente.
Verificação de Propriedades e Verificação de Propriedades em Excesso
O TypeScript é baseado em um sistema de tipos estrutural, mas a verificação de propriedades em excesso é uma propriedade do TypeScript que permite verificar se um objeto tem exatamente as propriedades especificadas no tipo.
A Verificação de Propriedades em Excesso é realizada ao atribuir literais de objeto a variáveis ou ao passá-los como argumentos para a propriedade em excesso da função, por exemplo.
type X = { a: string;};const y = { a: 'a', b: 'b' };const x: X = y; // Válido por causa da tipagem estruturalconst w: X = { a: 'a', b: 'b' }; // Inválido por causa da verificação de propriedades em excessoTipos Fracos
Um tipo é considerado fraco quando contém apenas um conjunto de propriedades todas opcionais:
type X = { a?: string; b?: string;};O TypeScript considera um erro atribuir qualquer coisa a um tipo fraco quando não há sobreposição, por exemplo, o seguinte gera um erro:
type Options = { a?: string; b?: string;};
const fn = (options: Options) => undefined;
fn({ c: 'c' }); // InválidoEmbora não seja recomendado, se necessário, é possível contornar essa verificação usando asserção de tipo:
type Options = { a?: string; b?: string;};const fn = (options: Options) => undefined;fn({ c: 'c' } as Options); // VálidoOu adicionando unknown à assinatura de índice ao tipo fraco:
type Options = { [prop: string]: unknown; a?: string; b?: string;};
const fn = (options: Options) => undefined;fn({ c: 'c' }); // VálidoVerificação Estrita de Literais de Objeto (Frescor)
A verificação estrita de literais de objeto, às vezes chamada de “freshness”, é um recurso no TypeScript que ajuda a detectar propriedades em excesso ou mal escritas que de outra forma passariam despercebidas nas verificações de tipo estrutural normais.
Ao criar um literal de objeto, o compilador TypeScript o considera “fresh”. Se o literal de objeto for atribuído a uma variável ou passado como parâmetro, o TypeScript gerará um erro se o literal de objeto especificar propriedades que não existem no tipo de destino.
No entanto, o “freshness” desaparece quando um literal de objeto é ampliado ou uma asserção de tipo é usada.
Aqui estão alguns exemplos para ilustrar:
type X = { a: string };type Y = { a: string; b: string };
let x: X;x = { a: 'a', b: 'b' }; // Verificação de frescor: Atribuição inválidavar y: Y;y = { a: 'a', bx: 'bx' }; // Verificação de frescor: Atribuição inválida
const fn = (x: X) => console.log(x.a);
fn(x);fn(y); // Ampliação: Sem erros, estruturalmente compatível com o tipo
fn({ a: 'a', bx: 'b' }); // Verificação de frescor: Argumento inválido
let c: X = { a: 'a' };let d: Y = { a: 'a', b: '' };c = d; // Ampliação: Sem verificação de frescorInferência de Tipo
O TypeScript pode inferir tipos quando nenhuma anotação é fornecida durante:
- Inicialização de variável.
- Inicialização de membro.
- Definição de padrões para parâmetros.
- Tipo de retorno de função.
Por exemplo:
let x = 'x'; // O tipo inferido é stringO compilador TypeScript analisa o valor ou expressão e determina seu tipo com base nas informações disponíveis.
Inferências Mais Avançadas
Quando múltiplas expressões são usadas na inferência de tipo, o TypeScript procura os “melhores tipos comuns”. Por exemplo:
let x = [1, 'x', 1, null]; // O tipo inferido é: (string | number | null)[]Se o compilador não conseguir encontrar os melhores tipos comuns, ele retorna um tipo união. Por exemplo:
let x = [new RegExp('x'), new Date()]; // Tipo inferido é: (RegExp | Date)[]O TypeScript utiliza “tipagem contextual” baseada na localização da variável para inferir tipos. No exemplo a seguir, o compilador sabe que e é do tipo MouseEvent por causa do tipo do evento click definido no arquivo lib.d.ts, que contém declarações ambientes para várias construções JavaScript comuns e o DOM:
window.addEventListener('click', function (e) {}); // O tipo inferido de e é MouseEventAmpliação de Tipo
Ampliação de tipo é o processo no qual o TypeScript atribui um tipo a uma variável inicializada quando nenhuma anotação de tipo foi fornecida. Permite tipos estreitos para mais amplos, mas não vice-versa. No exemplo a seguir:
let x = 'x'; // TypeScript infere como string, um tipo amplolet y: 'y' | 'x' = 'y'; // o tipo de y é uma união de tipos literaisy = x; // Inválido Type 'string' is not assignable to type '"x" | "y"'.O TypeScript atribui string a x com base no único valor fornecido durante a inicialização (x), este é um exemplo de ampliação.
O TypeScript fornece maneiras de ter controle sobre o processo de ampliação, por exemplo, usando “const”.
Const
Usar a palavra-chave const ao declarar uma variável resulta em uma inferência de tipo mais restrita no TypeScript.
Por exemplo:
const x = 'x'; // TypeScript infere o tipo de x como 'x', um tipo mais restritolet y: 'y' | 'x' = 'y';y = x; // Válido: O tipo de x é inferido como 'x'Ao usar const para declarar a variável x, seu tipo é restringido ao valor literal específico ‘x’. Como o tipo de x é restringido, ele pode ser atribuído à variável y sem nenhum erro.
A razão pela qual o tipo pode ser inferido é porque as variáveis const não podem ser reatribuídas, então seu tipo pode ser restringido a um tipo literal específico, neste caso, o tipo literal ‘x’.
Modificador Const em Parâmetros de Tipo
A partir da versão 5.0 do TypeScript, é possível especificar o atributo const em um parâmetro de tipo genérico. Isso permite inferir o tipo mais preciso possível. Vejamos um exemplo sem usar const:
function identity<T>(value: T) { // Sem const aqui return value;}const values = identity({ a: 'a', b: 'b' }); // Tipo inferido é: { a: string; b: string; }Como você pode ver, as propriedades a e b são inferidas com um tipo de string .
Agora, vejamos a diferença com a versão const:
function identity<const T>(value: T) { // Usando modificador const em parâmetros de tipo return value;}const values = identity({ a: 'a', b: 'b' }); // Tipo inferido é: { a: "a"; b: "b"; }Agora podemos ver que as propriedades a e b são inferidas como const, então a e b são tratadas como literais de string em vez de apenas tipos string.
Asserção Const
Este recurso permite que você declare uma variável com um tipo literal mais preciso baseado em seu valor de inicialização, sinalizando ao compilador que o valor deve ser tratado como um literal imutável. Aqui estão alguns exemplos:
Em uma única propriedade:
const v = { x: 3 as const,};v.x = 3;Em um objeto inteiro:
const v = { x: 1, y: 2,} as const;Isso pode ser particularmente útil ao definir o tipo para uma tupla:
const x = [1, 2, 3]; // number[]const y = [1, 2, 3] as const; // Tupla de readonly [1, 2, 3]Anotação de Tipo Explícita
Podemos ser específicos e passar um tipo, no exemplo a seguir a propriedade x é do tipo number:
const v = { x: 1, // Tipo inferido: number (ampliação)};v.x = 3; // VálidoPodemos tornar a anotação de tipo mais específica usando uma união de tipos literais:
const v: { x: 1 | 2 | 3 } = { x: 1, // x agora é uma união de tipos literais: 1 | 2 | 3};v.x = 3; // Válidov.x = 100; // InválidoEstreitamento de Tipo
Estreitamento de Tipo é o processo no TypeScript onde um tipo geral é reduzido a um tipo mais específico. Isso ocorre quando o TypeScript analisa o código e determina que certas condições ou operações podem refinar a informação de tipo.
O estreitamento de tipos pode ocorrer de diferentes maneiras, incluindo:
Condições
Ao usar instruções condicionais, como if ou switch, o TypeScript pode reduzir o tipo com base no resultado da condição. Por exemplo:
let x: number | undefined = 10;
if (x !== undefined) { x += 100; // O tipo é number, que foi reduzido pela condição}Lançando ou retornando
Lançar um erro ou retornar cedo de um ramo pode ser usado para ajudar o TypeScript a reduzir um tipo. Por exemplo:
let x: number | undefined = 10;
if (x === undefined) { throw 'error';}x += 100;Outras maneiras de reduzir tipos no TypeScript incluem:
- Operador
instanceof: Usado para verificar se um objeto é uma instância de uma classe específica. - Operador
in: Usado para verificar se uma propriedade existe em um objeto. - Operador
typeof: Usado para verificar o tipo de um valor em tempo de execução. - Funções integradas como
Array.isArray(): Usadas para verificar se um valor é um array.
União Discriminada
Usar uma “União Discriminada” é um padrão no TypeScript onde uma “tag” explícita é adicionada a objetos para distinguir entre diferentes tipos dentro de uma união. Este padrão também é chamado de “união marcada”. No exemplo a seguir, a “tag” é representada pela propriedade “type”:
type A = { type: 'type_a'; value: number };type B = { type: 'type_b'; value: string };
const x = (input: A | B): string | number => { switch (input.type) { case 'type_a': return input.value + 100; // tipo é A case 'type_b': return input.value + 'extra'; // tipo é B }};Type Guards Definidos pelo Usuário
Nos casos em que o TypeScript não consegue determinar um tipo, é possível escrever uma função auxiliar conhecida como “type guard definido pelo usuário”. No exemplo a seguir, utilizaremos um Type Predicate para reduzir o tipo após aplicar certa filtragem:
const data = ['a', null, 'c', 'd', null, 'f'];
const r1 = data.filter(x => x != null); // O tipo é (string | null)[], TypeScript não conseguiu inferir o tipo corretamente
const isValid = (item: string | null): item is string => item !== null; // Type guard personalizado
const r2 = data.filter(isValid); // O tipo está correto agora string[], ao usar o predicado de type guard conseguimos reduzir o tipo