Pular para o conteúdo

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álido

Regras 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 X
const 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álido
x = y; // Válido

Os 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álido
x = y; // Inválido

O 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álido
y = x; // Inválido, membro b está faltando

Descartar 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 faltando
y = x; // Válido

Quaisquer 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álido
x = y; // Válido

Quaisquer 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álido
x = y; // Válido

O 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álido

Funçõ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álido
x('a', 1); // Válido
function y(a: string): void; // Inválido, não compatível com a assinatura de implementação
function 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).

// Supertipo
class X {
a: string;
constructor(value: string) {
this.a = value;
}
}
// Subtipo
class Y extends X {}
// Subtipo
class Z extends X {}
type GetA = (x: X) => string;
const getA: GetA = x => x.a;
// Bivariância aceita supertipos
console.log(getA(new X('x'))); // Válido
console.log(getA(new Y('Y'))); // Válido
console.log(getA(new Z('z'))); // Válido

Enums 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álido
const ya: Y = 0; // Válido
X.A === Y.A; // Inválido

Instâ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álido

A 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álido
x === z; // Válido mesmo que z seja de uma hierarquia de herança diferente

Generics 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 final
interface 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 final

Quando 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álido

Lembre-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 any
let e2: any = e; // Válido
let 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 any
g = g1; // Válido

Por 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 conjuntoTypeScriptNotas
Conjunto vazionever”never” não contém nada além de si mesmo
Conjunto de elemento únicoundefined / null / tipo literal
Conjunto finitoboolean / union
Conjunto infinitostring / number / object
Conjunto universalany / unknownCada elemento é um membro de “any” e todo conjunto é um subconjunto dele / “unknown” é uma contraparte type-safe de “any”

Aqui estão alguns exemplos:

TypeScriptTermo de conjuntoExemplo
never∅ (conjunto vazio)const x: never = ‘x’; // Erro: Type ‘string’ is not assignable to type ‘never’
Tipo literalConjunto de elemento únicotype X = ‘X’;
type Y = 7;
Valor atribuível a TValor ∈ T (membro de)type XY = ‘X’ | ‘Y’;
const x: XY = ‘X’;
T1 atribuível a T2T1 ⊆ 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 T2T1 ⊆ T2 (subconjunto de)type X = ‘X’ extends string ? true : false;
T1 | T2T1 ∪ T2 (união)type XY = ‘X’ | ‘Y’;
type JK = 1 | 2;
T1 & T2T1 ∩ T2 (interseção)type X = { a: string }
type Y = { b: string }
type XY = X & Y
const x: XY = { a: ‘a’, b: ‘b’ }
unknownConjunto universalconst 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álido

Uma 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álido
const j: XY = { a: 'a', b: 'b' }; // Válido

A 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álido

Atribuir 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 tipo
const 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:

Terminal window
npm install --save-dev @types/library-name

Para 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 estrutural
const w: X = { a: 'a', b: 'b' }; // Inválido por causa da verificação de propriedades em excesso

Tipos 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álido

Embora 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álido

Ou 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álido

Verificaçã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álida
var 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 frescor

Inferê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 é string

O 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 é MouseEvent

Ampliaçã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 amplo
let y: 'y' | 'x' = 'y'; // o tipo de y é uma união de tipos literais
y = 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 restrito
let 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álido

Podemos 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álido
v.x = 100; // Inválido

Estreitamento 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