Mapped types e conditional types no TypeScript
1. Visão geral
Em um projeto TypeScript, você frequentemente tem que escrever muitos tipos similares: Order, depois ReadonlyOrder, OrderUpdate, OrderGetters… Escrever cada tipo à mão é trabalhoso e fácil de ficar inconsistente quando Order muda.
Mapped types e conditional types resolvem exatamente este problema. Eles permitem que você gere novos tipos a partir de tipos existentes. Depois de ler este artigo, você será capaz de escrever seus próprios utility types como Partial, PickByType, UnwrapPromise. Você também saberá como testá-los e quando parar.
2. Preparação
Você precisa de TypeScript 5.x com strict habilitado. Este artigo usa Vitest para testar tipos:
npm install -D typescript vitest
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true
}
}
strict: true habilita também strictNullChecks. Alguns resultados neste artigo, como se undefined corresponde a string, dependem dessa flag.
Os exemplos compartilham o seguinte tipo:
export interface Order {
id: string;
customerId: string;
total: number;
paid: boolean;
note?: string;
}
3. Mapped types
3.1. Sintaxe básica
Um mapped type percorre um conjunto de chaves e cria uma propriedade para cada chave:
type ReadonlyOrder = {
readonly [K in keyof Order]: Order[K];
};
const order: ReadonlyOrder = {
id: "o-1",
customerId: "c-1",
total: 250_000,
paid: false,
};
order.paid = true; // Erro de compilação
Explicação:
keyof Orderé a união"id" | "customerId" | "total" | "paid" | "note".[K in ...]itera sobre cada chave na união.Order[K]é um indexed access type. Ele obtém o tipo da propriedadeK.- Um mapped type da forma
[K in keyof T]é chamado de homomorphic. Essa forma preserva os modificadores originais, entãonotecontinua sendo opcional.
A linha de atribuição final relata um erro:
error TS2540: Cannot assign to 'paid' because it is a read-only property.
O exemplo acima ainda está hardcoded para Order. Para reutilizar com qualquer tipo, você adiciona um parâmetro genérico:
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyPartial<T> = { [K in keyof T]?: T[K] };
type OrderUpdate = MyPartial<Order>;
// { id?: string; customerId?: string; total?: number; paid?: boolean; note?: string }
É assim que a biblioteca padrão define Readonly<T> e Partial<T>.
3.2. Adicionando e removendo modificadores
Você pode colocar + ou - antes de readonly e ?. O sinal + é o padrão e geralmente é omitido. O sinal - é usado para remover um modificador:
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Concrete<T> = { [K in keyof T]-?: T[K] };
type CompleteOrder = Concrete<Order>;
const draft: CompleteOrder = {
id: "o-2",
customerId: "c-9",
total: 99_000,
paid: true,
note: "Entrega em horário comercial",
};
Com Concrete<Order>, a propriedade note se torna obrigatória. Seu tipo é string, não mais string | undefined. A biblioteca padrão tem Required<T> que faz exatamente isso.
3.3. Renomeando chaves com as
A partir do TypeScript 4.1, você pode renomear chaves usando a cláusula as. Isso geralmente vem junto com template literal types:
interface Customer {
id: string;
name: string;
email: string;
}
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type CustomerGetters = Getters<Customer>;
// {
// getId: () => string;
// getName: () => string;
// getEmail: () => string;
// }
A parte string & K é obrigatória. keyof T pode conter number e symbol, enquanto Capitalize aceita apenas string. A intersecção filtra as chaves que não são strings.
Você também pode usar as para filtrar chaves. Se a expressão retorna never, essa chave é excluída do resultado:
type WithoutId<T> = {
[K in keyof T as Exclude<K, "id">]: T[K];
};
type NewOrder = WithoutId<Order>;
// { customerId: string; total: number; paid: boolean; note?: string }
Exclude aqui é um conditional type. Esse é o tópico da próxima seção.
4. Conditional types
4.1. Sintaxe extends ? :
Um conditional type funciona como o operador ternário, mas no nível de tipo:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
T extends string significa “T é atribuível a string?”. Isso não é uma relação de herança de classe.
4.2. Distributividade
Quando T é um parâmetro genérico nu e você passa uma union, TypeScript aplica a condição a cada membro da union:
type ToArray<T> = T extends unknown ? T[] : never;
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type C = ToArray<string | number>; // string[] | number[]
type D = ToArrayNonDist<string | number>; // (string | number)[]
Os dois resultados são completamente diferentes. Envolver T em um tupla [T] desabilita a distributividade.
A distributividade é a base de Exclude e Extract:
type MyExclude<T, U> = T extends U ? never : T;
type Status = "pending" | "paid" | "cancelled";
type ActiveStatus = MyExclude<Status, "cancelled">; // "pending" | "paid"
TypeScript considera cada membro. "cancelled" se torna never, e never desaparece automaticamente da union.
Observação: Um conditional type distributivo que recebe
neverretornanever, porqueneveré uma union vazia. Por exemplo,ToArray<never>énever, nãonever[]. Se você precisa detectarnever, use a forma com tupla:[T] extends [never] ? ... : ....
4.3. Extraindo tipos com infer
infer permite que você “capture” uma parte do tipo no branch da condição:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
async function fetchOrder(id: string): Promise<Order> {
return { id, customerId: "c-1", total: 0, paid: false };
}
type FetchResult = ReturnType<typeof fetchOrder>; // Promise<Order>
type FetchedOrder = UnwrapPromise<FetchResult>; // Order
type Item = ElementOf<Order[]>; // Order
O significado de cada parte:
Promise<infer U>declara uma variável de tipoUe deixa TypeScript inferir seu valor.- Se corresponder, o branch
truepode usarU. Se não corresponder,UnwrapPromiseretornaTintacto. ReturnTypeeParametersna biblioteca padrão também são escritos usandoinfer.
Na prática, você deve usar Awaited<T> que já vem pronto. Ele lida com Promises aninhadas como Promise<Promise<Order>>.
5. Combinando as duas técnicas
5.1. Selecionando propriedades por tipo de valor
Colocando um conditional type dentro da cláusula as, você pode filtrar chaves pelo tipo do valor:
type PickByType<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
type OrderNumbers = PickByType<Order, number>; // { total: number }
type OrderStrings = PickByType<Order, string>; // { id: string; customerId: string }
Note que note não aparece em OrderStrings. O tipo de Order["note"] é string | undefined, e esse tipo não é atribuível a string com strictNullChecks ativado. Aqui T[K] não é um parâmetro nu, então a condição não é distributiva. A union é considerada como um bloco.
5.2. Handlers para eventos com tipos fortes
Este é um exemplo próximo ao código real. Você tem uma tabela de eventos para pedidos e quer gerar tipos para os handlers:
type OrderEvents = {
created: { orderId: string; total: number };
paid: { orderId: string; paidAt: Date };
cancelled: { orderId: string; reason: string };
};
type EventHandlers<E> = {
[K in keyof E as `on${Capitalize<string & K>}`]: (payload: E[K]) => void;
};
const handlers: EventHandlers<OrderEvents> = {
onCreated: (p) => console.log(`Criando pedido ${p.orderId}: ${p.total}`),
onPaid: (p) => console.log(`Pagamento confirmado às ${p.paidAt.toISOString()}`),
onCancelled: (p) => console.log(`Cancelado por: ${p.reason}`),
};
handlers.onCreated({ orderId: "o-1", total: 250_000 });
Criando pedido o-1: 250000
O parâmetro p em cada handler é inferido com o tipo correto, então você não precisa declará-lo. Quando adicionar um evento refunded a OrderEvents, TypeScript reportará um erro imediatamente porque handlers está faltando onRefunded.
5.3. Implementação em runtime para tipos gerados
Mapped types existem apenas em tempo de compilação. Para ter um objeto real, você ainda precisa escrever a lógica:
export function toGetters<T extends object>(obj: T): Getters<T> {
const result: Record<string, () => unknown> = {};
for (const key of Object.keys(obj) as (keyof T & string)[]) {
const name = `get${key.charAt(0).toUpperCase()}${key.slice(1)}`;
result[name] = () => obj[key];
}
return result as unknown as Getters<T>;
}
Por que precisamos fazer casting? TypeScript não pode provar que o loop em runtime cria a forma exata de Getters<T>. O cast as unknown as é onde você se compromete com isso. Portanto, este código obrigatoriamente precisa ter testes.
6. Testando tipos com Vitest
Vitest tem expectTypeOf para verificar tipos, junto com assertions de runtime usuais:
import { describe, it, expect, expectTypeOf } from "vitest";
import type { Order } from "./order";
import { toGetters } from "./getters";
describe("utility types", () => {
it("PickByType mantém apenas propriedades com tipos correspondentes", () => {
expectTypeOf<PickByType<Order, number>>().toEqualTypeOf<{ total: number }>();
expectTypeOf<PickByType<Order, string>>()
.toEqualTypeOf<{ id: string; customerId: string }>();
});
it("UnwrapPromise desembrulha uma Promise um nível", () => {
expectTypeOf<UnwrapPromise<Promise<Order>>>().toEqualTypeOf<Order>();
expectTypeOf<UnwrapPromise<number>>().toEqualTypeOf<number>();
});
it("toGetters retorna getters com os valores corretos", () => {
const getters = toGetters({ id: "c-1", name: "Ana", email: "ana@example.com" });
expectTypeOf(getters.getName).returns.toEqualTypeOf<string>();
expect(getters.getName()).toBe("Ana");
expect(getters.getEmail()).toBe("ana@example.com");
// @ts-expect-error getter não existente deve reportar erro de compilação
getters.getPhone;
});
});
Executar testes junto com verificação de tipo:
npx vitest run --typecheck
Observe os seguintes pontos:
- Em runtime,
expectTypeOfnão faz nada. A verificação real acontece quando o compilador analisa o arquivo. Por isso você precisa da flag--typecheck, ou executarnpx tsc --noEmitadicionalmente na CI. // @ts-expect-erroré usado para testes negativos. Se a linha abaixo não tiver mais um erro, por exemplo quando alguém faz o tipo mais permissivo, essa própria diretiva causará um erro de compilação.- O teste de runtime para
toGettersajuda a capturar casos onde a lógica de runtime diverge do tipo comprometido.
7. Erros comuns e quando não usar
Esquecer string & K ao usar template literals. Escrever `get${Capitalize<K>}` resultará em erro, porque K pode ser number | symbol.
Não prestar atenção à distributividade. Você escreve IsString<string | number> esperando false, mas o resultado é boolean, ou seja, true | false. Se quiser tratar a union como um bloco, envolva em um tupla.
Confundindo extends com any. any faz a condição retornar a união de ambos os branches. Por exemplo, IsString<any> é boolean.
Perder modificadores ao mapear sobre chaves não homomorphic. Um mapped type [K in "id" | "total"]: Order[K] não preserva readonly ou ? do Order. Apenas a forma [K in keyof T] preserva modificadores. Se você precisa selecionar algumas chaves mantendo os modificadores, use Pick.
Recursão muito profunda. Tipos como DeepPartial<T> que aplicam recursão em objetos aninhados podem causar o erro Type instantiation is excessively deep and possibly infinite. Isso também torna o editor perceptivelmente mais lento. Além disso, Date, Map e arrays também são object, então a condição T[K] extends object frequentemente os captura incorretamente.
Observação: Não use mapped types e conditional types apenas para parecer inteligente. Se um tipo é usado em um único lugar e raramente muda, uma interface escrita à mão será muito mais legível. Mensagens de erro de tipos aninhados em 3–4 níveis são muito difíceis de entender para colegas. Priorize utility types disponíveis (
Partial,Pick,Omit,Record,Exclude,ReturnType,Awaited) antes de escrever os seus próprios.
Acreditar que tipos protegem o runtime. Mapped types não validam dados vindos de APIs. Se o JSON retornado está faltando um campo, Getters<Customer> ainda é “correto” no tempo de compilação mas errado em runtime. Com dados vindos de fora, você precisa adicionar uma etapa de validação em runtime.
8. Conclusão
Este artigo introduziu duas ferramentas fundamentais para gerar tipos no TypeScript:
- Mapped types, com
[K in keyof T], permitem iterar sobre chaves, adicionar ou removerreadonly/?, renomear e filtrar chaves comas. - Conditional types, com
T extends U ? X : Y, permitem ramificação no nível de tipo. Eles distribuem sobre unions e extraem subtipos cominfer.
Quando você combina as duas técnicas, consegue escrever tipos como PickByType e EventHandlers. Esses tipos se atualizam automaticamente quando o tipo base muda. Sempre inclua testes de tipo com expectTypeOf e @ts-expect-error. Execute --typecheck na CI. E mantenha esses tipos simples o suficiente para que outras pessoas entendam.