Em algum momento todo projeto TypeScript chega nesse ponto: você já tem um tipo que descreve um objeto, e agora precisa de uma variação dele. Quase igual, mas com todos os campos opcionais. Ou com todos os campos aceitando null. Ou com todos somente leitura.
A solução ingênua é copiar o tipo e ajustar campo por campo, na mão. Funciona, até o tipo original ganhar um campo novo. A cópia não acompanha sozinha, e nada te avisa disso.
Mapped types são a resposta do TypeScript para esse problema: em vez de reescrever cada campo, você descreve a transformação uma vez e manda o compilador percorrer todas as chaves de um tipo existente aplicando ela automaticamente. Adicione um campo ao tipo original, e toda variação construída a partir dele se atualiza sozinha no próximo compile, sem edição manual nenhuma.
Só o type consegue expressar isso. Um interface não tem mecanismo equivalente, e no final deste artigo vai ficar claro exatamente por quê.
Perto do final deste artigo você vai encontrar links para o TypeScript Playground oficial, com o código já pronto para rodar sem precisar instalar nada. Se preferir, também pode ir copiando os exemplos abaixo e testando na sua própria IDE ao longo da leitura.
Antes de chegar lá, uma passada rápida por três conceitos que este artigo assume que você já conhece: object types, union types e string literal types. Se você já domina os três, pule direto para a próxima seção.
As três peças de base
Um object type descreve a forma de um objeto:
type Entry = { id: string; minutes: number };
Um union type descreve um valor que pode ser de mais de um tipo, separados por |:
type MaybeNumber = number | null;
Um string literal type é quando um texto específico vira um tipo em si, não apenas um string genérico:
type OnlyId = "id";
E os três se combinam: "id" | "minutes" é uma union de string literal types, um tipo que aceita exatamente esses dois textos e nenhum outro. Esses três conceitos são a chave para entender os próximos.
O problema que os mapped types resolvem
Volte ao Entry acima. Suponha que você precise de um segundo tipo quase idêntico, mas em que todo campo também pode ser null (um rascunho de formulário antes do usuário preencher, por exemplo). Sem nenhuma ferramenta especial, você escreve na mão, campo por campo:
type Entry = { id: string; minutes: number };
// Manual, campo por campo: cada campo repetido e transformado na mão.
type NullableEntryManual = {
id: string | null;
minutes: number | null;
};
const draft: NullableEntryManual = { id: null, minutes: 90 };
console.log(draft.id, draft.minutes); // null 90
Funciona, e para dois campos está de bom tamanho. Mas repare que você digitou id e minutes pela segunda vez, e aplicou a mesma transformação, adicionar | null, em cada um dos campos, na mão. Se Entry ganhar um terceiro campo depois, o NullableEntryManual não se atualiza sozinho. Você teria que lembrar de voltar nele e editar também, e nada te avisa se você esquecer.
É esse acoplamento frágil que o mapped type elimina. Mas para chegar na sintaxe dele, precisamos de duas peças menores primeiro. Ambas só existem no TypeScript, nunca em código JavaScript comum.
Primeira peça: o operador keyof
keyof é uma palavra-chave exclusiva do TypeScript. Ela não existe em JavaScript. Como todo tipo aqui, ela é apagada antes do JavaScript ser emitido, então tem custo zero em tempo de execução. Ela existe só para o compilador raciocinar sobre os seus tipos.
O que ela faz é simples: keyof recebe um object type e produz a união dos nomes das chaves dele, como string literal types.
type Entry = { id: string; minutes: number };
// keyof lê um object type e produz a união dos nomes de suas chaves.
type EntryKeys = keyof Entry; // equivale a "id" | "minutes"
const entryKey: EntryKeys = "id";
console.log(entryKey); // id
Repare que aqui aparecem as duas peças da seção anterior juntas: keyof Entry é uma union de string literal types. Nada é transformado ainda. keyof Entry só responde a pergunta "quais são os nomes dos campos desse tipo, como uma união?".
Você pode provar que a resposta é exata, e não apenas "qualquer string", tentando uma chave que não existe:
type Entry = { id: string; minutes: number };
type EntryKeys = keyof Entry;
const invalidEntryKey: EntryKeys = "other"; // não compila
Isso não compila. O TypeScript rejeita com:
error TS2322: Type '"other"' is not assignable to type 'keyof Entry'.
Isso confirma que keyof Entry é mesmo o conjunto fechado "id" | "minutes", calculado a partir do próprio Entry em vez de escrito à mão.
Segunda peça: indexed access
Se o keyof responde "quais são as chaves", um indexed access type responde "qual é o tipo do valor guardado nesta chave". Essa peça tem um significado exclusivo do TypeScript montado em cima de um símbolo com cara de JavaScript: os colchetes [...] são exatamente os mesmos que o JavaScript usa para ler uma propriedade em tempo de execução (entry["minutes"]), mas aqui, escritos depois de um nome de tipo em vez de uma variável, eles rodam inteiramente em tempo de compilação e produzem um tipo, não um valor.
type Entry = { id: string; minutes: number };
// Indexed access: lê o tipo do valor guardado em uma chave.
type IdType = Entry["id"]; // string
type MinutesType = Entry["minutes"]; // number
const idValue: IdType = "e1";
const minutesValue: MinutesType = 42;
console.log(idValue, minutesValue); // e1 42
Entry["minutes"] não está lendo um valor em tempo de execução, Entry é um tipo, não um objeto, então não há nada a ler. Ele está perguntando ao compilador: "no tipo Entry, qual tipo está guardado sob a chave "minutes"?". A resposta vira um tipo próprio, number, que você pode usar em qualquer lugar onde um tipo é esperado.
Juntando as duas peças
Antes de olhar a sintaxe completa do mapped type, vale separar os dois símbolos que ela usa: as chaves { } externas e os colchetes [ ] internos. Nenhum dos dois faz nada que você ainda não tenha visto, uma vez isolados.
As chaves { } externas não têm nada de novo
Todo object type que você escreveu até aqui veio embrulhado em chaves: { x: number; y: number }, { id: string; minutes: number }. Um mapped type continua sendo um object type, então continua começando e terminando com { }. Nada muda aí.
O [ ] interno não é um array
Essa é a parte que parece estranha, então vale ancorar em algo que você provavelmente já conhece do JavaScript puro: computed property names. Em um object literal, se você quer que o nome de uma propriedade venha de uma variável em vez de ser digitado literalmente, você embrulha essa variável em colchetes:
// JavaScript puro, sem TypeScript ainda.
const key = "id";
const obj = { [key]: "e1" };
console.log(obj); // { id: 'e1' }
Aqui, [key] não está criando um array. Ele fica no lugar do nome da propriedade dentro do object literal, e diz ao JavaScript "use o valor da variável key como o nome desta propriedade", em vez de "a propriedade se chama literalmente key". Sem os colchetes, { key: "e1" } criaria uma propriedade chamada literalmente key, o que é outra coisa completamente diferente.
Um mapped type reutiliza exatamente esse mesmo lugar, a posição do nome da propriedade dentro das chaves { }. A diferença é que, em vez de calcular um único nome a partir de uma variável, como [key] fez, ele varre uma união inteira de chaves, uma de cada vez, usando duas peças novas.
type Toggles = { [K in "darkMode" | "compactView"]: boolean };
A primeira peça é o K. Ele não é uma palavra reservada, é só um nome escolhido por você, do mesmo jeito que key era um nome escolhido no exemplo em JavaScript. Na verdade, é o mesmo papel que a variável de um loop for...of exerce em JavaScript: em for (const item of list) { ... }, item é só um nome guardando o valor atual da iteração, e você poderia chamá-lo de qualquer coisa. K funciona igual, só que guarda a chave atual em vez de um item de lista. Poderia se chamar Prop ou Name sem mudar nada no resultado, mas a convenção da comunidade TypeScript é usar K (de "key"), e é isso que você vai encontrar no dia a dia.
A segunda peça é o in. É ele quem faz o trabalho de repetição: K in UNION significa "para cada membro da união UNION, rode esta linha uma vez, chamando o membro atual de K". É o in que transforma uma união estática em uma sequência de rodadas, uma por chave.
Leia a linha completa juntando as duas peças:
-
{ }diz "isto é um object type", como sempre. -
[K in "darkMode" | "compactView"]diz "para cada string literal type dentro desta união, rode uma vez, chamando o valor atual deK, e use essa string literal como nome da propriedade desta rodada".
Cuidado para não confundir com o outro
in: o JavaScript já tem um operadorinque checa se um objeto tem uma certa propriedade, como em"id" in someObject. É um operador diferente. Oindo mapped type só existe dentro dessa posição de colchete, em tempo de compilação, e some depois que o TypeScript vira JavaScript.
Ou seja, essa única linha está fazendo duas rodadas do mesmo template: uma com K = "darkMode", outra com K = "compactView", e cada rodada cria uma propriedade, darkMode: boolean e compactView: boolean, usando o tipo depois dos dois pontos como tipo de valor para as duas. O resultado é exatamente { darkMode: boolean; compactView: boolean }, só que gerado mecanicamente em vez de digitado na mão.
Do union escrito à mão para keyof
Agora troque a união escrita à mão por keyof Entry, para que as chaves venham de um tipo existente em vez de serem listadas na mão, e use indexed access para que cada propriedade mantenha o seu tipo original em vez de um tipo fixo:
type Entry = { id: string; minutes: number };
// Mapped type sem transformação: percorre cada chave de Entry mantendo o tipo
// do valor.
// O resultado é um tipo novo, mas com forma indistinguível de Entry.
type EntryCopy = { [K in keyof Entry]: Entry[K] };
const entryCopy: EntryCopy = { id: "e1", minutes: 90 };
const backToEntry: Entry = entryCopy; // intercambiáveis, mesma forma
console.log(entryCopy.id, entryCopy.minutes, backToEntry.minutes); // e1 90 90
O que { [K in keyof Entry]: Entry[K] } faz, passo a passo:
-
keyof Entrycalcula"id" | "minutes", o conjunto de chaves a percorrer. -
[K in ...]percorre essa união um membro de cada vez, chamando o membro atual deK. - Para cada
K,Entry[K]busca o tipo de valor original naquela mesma chave. - O resultado é uma propriedade nova, chamada
K, tipada exatamente como estava emEntry.
Esse mapped type em particular não muda nada, ele reproduz Entry campo por campo, e por isso pode ser chamado de mapped type sem transformação. Ele existe aqui só para isolar o mecanismo antes do próximo passo mexer nele.
Adicionando a transformação
O ponto inteiro de um mapped type é que o passo 4 acima, Entry[K], não precisa ficar intocado. Você pode embrulhar ele em qualquer coisa, e esse embrulho é aplicado a cada chave automaticamente:
type Entry = { id: string; minutes: number };
type NullableEntry = { [K in keyof Entry]: Entry[K] | null };
Agora Entry[K] | null significa "qualquer que fosse o tipo original deste campo, ou null". Como isso é calculado uma vez e aplicado a toda chave, não importa se Entry tem dois campos ou vinte: NullableEntry acompanha sozinho, sem nenhuma edição manual.
Compare com o NullableEntryManual lá do começo. O resultado é o mesmo, mas agora a transformação está descrita em um lugar só, e ela acompanha o Entry para sempre.
Por que isso importa: Partial, Readonly e companhia
Toda transformação campo por campo que você possa querer: deixar todos os campos opcionais, deixar todos somente leitura, deixar todos aceitando null, é um mapped type no fundo. Os utility types que o TypeScript já traz prontos, como Partial<T>, Required<T>, Readonly<T> e Record<K, V>, não são mágica especial do compilador: são mapped types comuns, já prontos pelo TypeScript, para você não precisar reescrever toda vez.
Como amostra, o Partial<T> é mais ou menos isso:
type Entry = { id: string; minutes: number };
// Uma versão manual e aproximada do que o Partial<T> te dá.
// O ? aqui é o modificador de opcional: ele pode ser aplicado dentro de um
// mapped type do mesmo jeito que se aplica a um campo normal.
type PartialEntry = { [K in keyof Entry]?: Entry[K] };
// O Partial<T> nativo do TypeScript faz exatamente a mesma coisa por baixo dos panos.
type PartialEntryBuiltIn = Partial<Entry>;
const patch: PartialEntry = { minutes: 45 }; // id pode ficar de fora
const builtInPatch: PartialEntryBuiltIn = { minutes: 45 }; // mesmo resultado
// Como os dois tipos têm a mesma forma, o valor de um serve perfeitamente como
// o outro.
const proof: PartialEntry = builtInPatch;
console.log(patch.minutes, builtInPatch.minutes, proof.minutes); // 45 45 45
O ? é a novidade aqui. Ele é o modificador de opcional, e a única razão de mostrá-lo agora é para que, quando você esbarrar no Partial<T> no dia a dia, ele seja reconhecível como "o mecanismo de mapped type que você já conhece, mais um modificador", e não uma ideia inteiramente nova. Rode o arquivo você mesmo e veja: PartialEntry e PartialEntryBuiltIn são intercambiáveis, porque são o mesmo mapped type escrito de duas formas.
O arquivo completo
Tudo acima se resume nos dois casos abaixo: um mapped type sobre uma união de chaves explícita, e um mapped type sobre keyof com uma transformação. É um arquivo que você pode rodar com npx tsc e depois node.
/*
Mapped type: constrói um tipo novo percorrendo um conjunto de chaves.
*/
// ---
// Caso A: percorre um conjunto explícito de chaves,
// dando a cada uma o mesmo tipo de valor.
type Toggles = { [K in "darkMode" | "compactView"]: boolean };
const settings: Toggles = { darkMode: true, compactView: false };
console.log(settings.darkMode, settings.compactView); // true false
// ---
// Caso B: percorre as chaves de um tipo existente, transformando cada valor.
type Entry = { id: string; minutes: number };
type NullableEntry = { [K in keyof Entry]: Entry[K] | null };
const draft: NullableEntry = { id: null, minutes: 90 };
console.log(draft.id, draft.minutes); // null 90
// ---
/*
Um corpo de interface não aceita a forma [K in ...] de mapped type.
Se você descomentar a linha abaixo e rodar npx tsc, recebe:
error TS1169: A computed property name in an interface must refer to
an expression whose type is a literal type or a 'unique symbol' type.
*/
// interface TogglesIface { [K in "darkMode" | "compactView"]: boolean }
A saída é:
true false
null 90
Deixei esse mesmo código disponível, pronto para você rodar, sem precisar instalar nada.
É só abrir o link e ver o resultado logo do lado direito da tela. Se preferir, copie e cole o código acima e rode na sua própria IDE.
De uma forma ou de outra, descomente a linha do interface para ver o erro TS1169 de perto. Em seguida, experimente trocar a união de Toggles por outras chaves, ou embrulhar Entry[K] em algo diferente de | null no NullableEntry. É treinando essas variações que o { [K in ...]: ... } deixa de parecer sintaxe decorada e passa a ser uma ferramenta que você sabe montar do zero.
Conclusão
Mapped types resolvem um problema chato e comum: manter tipos paralelos em sincronia. Em vez de copiar um tipo e transformar campo por campo na mão, você descreve a transformação uma vez, { [K in keyof T]: ... }, e o compilador aplica ela a toda chave. Adicione um campo ao tipo de origem, e cada variação derivada dele se atualiza sozinha sem mais trabalho manual e com menos propensão a bugs.
Depois de destrinchar o mecanismo, { [K in keyof Entry]: Entry[K] | null } se lê como uma frase: "para cada chave K de Entry, mantenha o tipo de valor original, mas também permita null". E o ganho vai além do caso pontual: Partial, Required, Readonly e Record deixam de parecer mágica e passam a ser o que são de fato, mapped types comuns que o TypeScript já escreveu por você. Entender a mecânica por baixo deles é o que te permite ir além dos prontos e escrever a sua própria transformação quando nenhum utility type existente resolve.
Se quiser revisitar todos os exemplos deste artigo juntos, num único arquivo, prontos para você testar sem precisar montar nada do zero, aqui está:
Os exemplos estão organizados na mesma ordem em que apareceram no texto.
Top comments (0)