Template literal types em TypeScript através de exemplos

1. Visão geral

Template literal types permitem você usar a sintaxe de backtick diretamente no sistema de tipos do TypeScript. Com isso, você pode concatenar, transformar e analisar tipos de strings na hora do compile. Este artigo passa por cada recurso com exemplos concretos: concatenar unions, renomear chaves em mapped types, extrair parâmetros de rotas e escrever um event emitter type-safe. Depois de ler, você saberá quando usar essa técnica. Você também saberá como testá-la e os lugares onde é fácil deixar o compilador lento ou gerar mensagens de erro difíceis de entender.

Todos os exemplos neste artigo usam TypeScript 5.x com strict: true.

2. Sintaxe básica

Um template literal type parece exatamente como uma template string em runtime. A diferença é que a parte de interpolação ${...} contém um tipo, não um valor:

type OrderId = `ORD-${number}`;

const ok: OrderId = "ORD-1024";
// const bad: OrderId = "INV-1024";
// Error: Type '"INV-1024"' is not assignable to type '`ORD-${number}`'.

O tipo OrderId aceita qualquer string que comece com ORD- seguida de uma representação de número. Você pode interpolar string, number, bigint, boolean, null, undefined e seus tipos literais correspondentes.

Um exemplo familiar é o valor de CSS com unidade:

type Pixel = `${number}px`;

function setWidth(el: HTMLElement, width: Pixel): void {
  el.style.width = width;
}

setWidth(document.body, "320px"); // OK
// setWidth(document.body, "320");  // Error: falta "px"

Agora o compilador detecta o erro de esquecer a unidade. Antes, esse tipo de erro apenas aparecia quando você abria o navegador.

3. Combinando com union: produto Cartesiano

Quando a parte interpolada é uma union, TypeScript distribui o template por cada membro. Se houver múltiplas unions, o resultado é cada combinação possível:

type Size = "sm" | "md" | "lg";
type Variant = "primary" | "danger";

type ButtonClass = `btn-${Size}-${Variant}`;

Passe o mouse sobre ButtonClass no editor, e você verá:

type ButtonClass = "btn-sm-primary" | "btn-sm-danger" | "btn-md-primary"
                 | "btn-md-danger" | "btn-lg-primary" | "btn-lg-danger"

Aqui 3 × 2 = 6 membros são gerados automaticamente. Quando você adiciona "xl" a Size, ButtonClass se atualiza automaticamente. Você não precisa modificar mais nada.

Nota: O número de combinações cresce exponencialmente. Se a union resultante exceder 100.000 membros, TypeScript lançará um erro “Expression produces a union type that is too complex to represent”. Não combine múltiplas unions grandes entre si, por exemplo, uma union de 1.000 códigos de produto combinada com uma union de 200 códigos de país.

4. Tipos de transformação de string integrados

TypeScript fornece quatro tipos intrínsecos para transformar maiúsculas/minúsculas:

type Event = "orderCreated";

type A = Uppercase<Event>;     // "ORDERCREATED"
type B = Lowercase<Event>;     // "ordercreated"
type C = Capitalize<Event>;    // "OrderCreated"
type D = Uncapitalize<"OrderCreated">; // "orderCreated"

Eles são construídos no compilador, não são escritos em TypeScript. Esses quatro tipos geralmente acompanham template literals para criar nomes seguindo convenção, como on${Capitalize<K>} ou ${Uppercase<K>}_ENV.

type ConfigKey = "databaseUrl" | "apiToken";
type EnvName = `APP_${Uppercase<ConfigKey>}`;
// "APP_DATABASEURL" | "APP_APITOKEN"

5. Renomeando chaves em mapped types

Este é o lugar onde template literal types mais brilham. A cláusula as em mapped types permite renomear cada chave ao criar um novo tipo.

5.1. Gerando getters a partir de uma interface

interface Customer {
  name: string;
  email: string;
  loyaltyPoints: number;
}

type Getters<T> = {
  [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};

type CustomerGetters = Getters<Customer>;

O resultado:

type CustomerGetters = {
  getName: () => string;
  getEmail: () => string;
  getLoyaltyPoints: () => number;
}

Explicando cada parte:

  • keyof T & string filtra chaves do tipo number e symbol. O motivo é que Capitalize aceita apenas string. Se você omitir & string, receberá um erro “Type ‘K’ does not satisfy the constraint ‘string’”.
  • as \get${Capitalize}`renomeianameparagetName`.
  • () => T[K] mantém o tipo de retorno correto da propriedade original.

Uma classe que implementa esse tipo terá um erro do compilador se faltar um getter ou retornar o tipo errado:

class CustomerView implements CustomerGetters {
  constructor(private readonly data: Customer) {}
  getName = () => this.data.name;
  getEmail = () => this.data.email;
  getLoyaltyPoints = () => this.data.loyaltyPoints;
}

5.2. Filtrando chaves retornando never

Se a expressão dentro de as resulta em never, essa chave é excluída do resultado. Você pode combinar isso com template literal para selecionar chaves por prefixo:

interface PaymentService {
  chargeCard(amount: number): Promise<void>;
  chargeWallet(amount: number): Promise<void>;
  refund(paymentId: string): Promise<void>;
}

type ChargeMethods<T> = {
  [K in keyof T as K extends `charge${string}` ? K : never]: T[K];
};

type OnlyCharge = ChargeMethods<PaymentService>;
// { chargeCard: ...; chargeWallet: ... }

refund não corresponde ao padrão charge${string}, então é excluído.

6. Analisando strings com infer

Template literals não servem apenas para concatenar strings. Você também pode usá-los para extrair strings em tipos condicionais, combinando com infer.

6.1. Extraindo uma parte simples

type StripPrefix<S extends string> =
  S extends `ORD-${infer Rest}` ? Rest : never;

type X = StripPrefix<"ORD-1024">; // "1024"
type Y = StripPrefix<"INV-1024">; // never

Quando a correspondência é bem-sucedida, TypeScript atribui o restante a Rest.

6.2. Extraindo parâmetros de rota

Este é o exemplo mais comum do mundo real. Da string "/users/:userId/orders/:orderId", queremos obter o tipo "userId" | "orderId":

type ExtractParams<Path extends string> =
  Path extends `${string}:${infer Param}/${infer Rest}`
    ? Param | ExtractParams<`/${Rest}`>
    : Path extends `${string}:${infer Param}`
      ? Param
      : never;

type P = ExtractParams<"/users/:userId/orders/:orderId">;
// "userId" | "orderId"

Como funciona:

  1. O primeiro branch corresponde a "/users/" + ":" + "userId" + "/" + "orders/:orderId". Cada placeholder corresponde à primeira ocorrência do caractere literal que vem depois dele. Por isso Param é "userId".
  2. Tipo recursivo com "/orders/:orderId". Desta vez não há / após o parâmetro, então o primeiro branch falha.
  3. O segundo branch corresponde e retorna "orderId".
  4. Se a string não contiver :, o resultado é never.

Agora use-o em uma função que constrói URL:

type RouteParams<Path extends string> = {
  [K in ExtractParams<Path>]: string;
};

function buildUrl<Path extends string>(
  path: Path,
  params: RouteParams<Path>,
): string {
  const values = params as Record<string, string>;
  return path.replace(/:(\w+)/g, (_, key: string) =>
    encodeURIComponent(values[key]),
  );
}

const url = buildUrl("/users/:userId/orders/:orderId", {
  userId: "42",
  orderId: "ORD-7",
});
console.log(url);

// buildUrl("/users/:userId", { id: "42" });
// Error: Object literal may only specify known properties, and 'id' does not exist...

Output:

/users/42/orders/ORD-7

O generic Path é inferido como um tipo literal do primeiro argumento. Graças a isso, o objeto params é verificado com precisão: chaves faltando, extras ou com nome errado são sinalizadas. O cast para Record<string, string> no corpo da função é necessário. TypeScript não pode provar que uma chave extraída da regex em runtime pertence a ExtractParams<Path>.

7. Exemplo completo: event emitter type-safe

Combinando as técnicas acima, temos um emitter para eventos de pedidos:

type OrderEvents = {
  created: { orderId: string; total: number };
  shipped: { orderId: string; carrier: string };
  cancelled: { orderId: string; reason: string };
};

type EventHandlers<E> = {
  [K in keyof E & string as `on${Capitalize<K>}`]?: (payload: E[K]) => void;
};

class OrderEmitter {
  constructor(private readonly handlers: EventHandlers<OrderEvents>) {}

  emit<K extends keyof OrderEvents>(event: K, payload: OrderEvents[K]): void {
    const name = `on${event[0].toUpperCase()}${event.slice(1)}` as
      `on${Capitalize<K>}`;
    const handler = this.handlers[name] as
      ((p: OrderEvents[K]) => void) | undefined;
    handler?.(payload);
  }
}

const emitter = new OrderEmitter({
  onCreated: (p) => console.log(`Created ${p.orderId}: ${p.total}`),
  onShipped: (p) => console.log(`Shipped via ${p.carrier}`),
});

emitter.emit("created", { orderId: "ORD-1", total: 250_000 });
emitter.emit("shipped", { orderId: "ORD-1", carrier: "GHN" });
// emitter.emit("shipped", { orderId: "ORD-1", reason: "x" }); // Error

Output:

Created ORD-1: 250000
Shipped via GHN

O que você ganha:

  • Os nomes dos handlers onCreated, onShipped, onCancelled são gerados automaticamente a partir de OrderEvents.
  • O parâmetro p em cada handler é inferido com o tipo correto do payload, sem necessidade de declaração.
  • emit("shipped", ...) força o payload a ter carrier.

Os dois as dentro de emit são onde dizemos ao compilador algo que ele não pode inferir sozinho. A string construída em runtime não carrega informação de tipo. Mantenha esse tipo de cast em escopo pequeno, dentro da implementação. A API pública permanece completamente type-safe.

8. Testando tipos com Vitest

A lógica no nível de tipos também precisa de testes. Caso contrário, um refactor pode transformar ExtractParams em string sem ninguém notar. Instale o Vitest:

npm install -D vitest typescript

Escreva testes tanto para comportamento de tipos quanto comportamento em runtime:

// route.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { buildUrl, type ExtractParams } from "./route";

describe("ExtractParams", () => {
  it("extrai nomes de parâmetros corretamente", () => {
    expectTypeOf<ExtractParams<"/users/:userId/orders/:orderId">>()
      .toEqualTypeOf<"userId" | "orderId">();
  });

  it("retorna never quando não há parâmetros", () => {
    expectTypeOf<ExtractParams<"/health">>().toBeNever();
  });
});

describe("buildUrl", () => {
  it("substitui parâmetros e codifica valores", () => {
    const url = buildUrl("/customers/:customerId", { customerId: "a b" });
    expect(url).toBe("/customers/a%20b");
  });

  it("rejeita chaves incorretas", () => {
    // @ts-expect-error - 'id' não é um parâmetro da rota
    buildUrl("/customers/:customerId", { id: "1" });
  });
});

Execute:

npx tsc --noEmit && npx vitest run

expectTypeOf é uma no-op em runtime. Seus erros só aparecem durante type-check, então o passo tsc --noEmit (ou vitest --typecheck) é obrigatório. A diretiva @ts-expect-error também é uma assertion. Se a linha abaixo não tiver mais um erro de tipo, tsc reportará “Unused ‘@ts-expect-error’ directive”.

9. Erros comuns e quando não usar

Nota: Template literal types só existem em tempo de compile. Dados de uma API, formulário ou process.env não são verificados. Com dados externos, você ainda precisa validar em runtime com regex ou uma biblioteca de schema.

Algumas armadilhas específicas:

  • ${number} é mais amplo do que você pensa. "1e3px" ou "0.5px" são válidos para `${number}px`. Se você precisa de “número positivo inteiro”, template literals não podem expressar isso. Valide em runtime.
  • Union explode. Como na seção 3, combinar muitas unions grandes deixa o compilador muito lento, podendo até gerar erro de complexidade.
  • Recursão profunda. Tipos recursivos como ExtractParams têm limite de profundidade. Com strings muito longas, você encontrará “Type instantiation is excessively deep and possibly infinite”. Rotas típicas não atingem esse limite, mas parsers de string genéricos podem.
  • Mensagens de erro ilegíveis. Com tipos complexos, erros podem ter dezenas de linhas. Se seus colegas tiverem que ler esse erro diariamente, o custo pode superar o benefício.
  • Falta de & string. keyof T pode conter number | symbol, e Capitalize rejeitará esses tipos.
  • Abuso. Se o conjunto de valores é pequeno e fixo, escreva a union literal diretamente. Escrever type Size = "sm" | "md" é mais legível do que qualquer tipo gerado automaticamente.

Use quando houver uma fonte de verdade (interface, objeto de eventos, string de rota) e você quer que outros tipos sejam inferidos automaticamente a partir dela. Não use apenas para mostrar habilidades com tipos.

10. Conclusão

Template literal types tornam strings cidadãs de primeira classe no sistema de tipos do TypeScript. Este artigo passou pelos principais técnicas:

  • concatenar strings e distribuir sobre unions;
  • transformar maiúsculas/minúsculas com Uppercase, Capitalize…;
  • renomear e filtrar chaves em mapped types com as;
  • extrair strings com infer, incluindo de forma recursiva.

Quando aplicadas corretamente, como em parâmetros de rota, nomes de eventos ou getters, você elimina uma classe inteira de erros de digitação que anteriormente só apareceriam em runtime. Lembre-se de testar tipos com expectTypeOf e @ts-expect-error. Observe a complexidade das unions e a profundidade da recursão. E sempre valide dados que chegam de fora do programa.

typescripttemplate literal typestype systemgenericsvitest