Ir para o conteúdo
·21 min de leitura

Tipos Avançados do TypeScript: Condicionais, Mapped Types e Template Literal Types

Um mergulho profundo nos recursos mais poderosos do sistema de tipos do TypeScript — tipos condicionais, mapped types, a palavra-chave infer e template literal types. Padrões reais que você vai usar de verdade.

Compartilhar:X / TwitterLinkedIn

A maioria dos desenvolvedores TypeScript estagna no mesmo ponto. Eles sabem interfaces, generics, union types. Conseguem tipar um componente React. Conseguem fazer os riscos vermelhos sumirem. Mas tratam o sistema de tipos como um mal necessário — algo que existe para prevenir bugs, não algo que os ajuda ativamente a projetar software melhor.

Fiquei nesse platô por uns dois anos. O que me tirou de lá foi perceber que o sistema de tipos do TypeScript é uma linguagem de programação por si só. Ele tem condicionais, loops, pattern matching, recursão e manipulação de strings. Quando você internaliza isso, tudo muda. Você para de brigar com o compilador e começa a colaborar com ele.

Este post cobre os recursos de programação em nível de tipos que eu uso constantemente em código de produção. Não são exercícios acadêmicos — são padrões reais que me salvaram de bugs reais.

A Mentalidade de Programação em Nível de Tipos#

Antes de mergulhar na sintaxe, quero reformular como você pensa sobre tipos.

Na programação em nível de valor, você escreve funções que transformam dados em tempo de execução:

typescript
function extractIds(users: User[]): number[] {
  return users.map(u => u.id);
}

Na programação em nível de tipos, você escreve tipos que transformam outros tipos em tempo de compilação:

typescript
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];

O modelo mental é o mesmo — entrada, transformação, saída. A diferença é que código em nível de tipos roda durante a compilação, não em tempo de execução. Ele produz zero JavaScript. Seu único propósito é tornar estados impossíveis irrepresentáveis.

Se você já escreveu uma função onde três dos quatro parâmetros só fazem sentido em certas combinações, ou onde o tipo de retorno depende do que você passa, você já precisou de programação em nível de tipos. Talvez só não soubesse que o TypeScript conseguia expressar isso.

Tipos Condicionais#

Tipos condicionais são o if/else do sistema de tipos. A sintaxe parece um ternário:

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

A palavra-chave extends aqui não significa herança. Significa "é atribuível a". Pense nisso como "T encaixa no formato de string?"

Construindo Seus Próprios Utility Types#

Vamos reconstruir alguns dos utility types nativos do TypeScript para entender como funcionam.

NonNullable — remove null e undefined de uma union:

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Result: string

O tipo never é o "conjunto vazio" — remove algo de uma union. Quando um ramo de um tipo condicional resolve para never, aquele membro da union simplesmente desaparece.

Extract — mantém apenas membros da union que correspondem a uma restrição:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | boolean

Exclude — o oposto, remove membros que correspondem:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | boolean

Tipos Condicionais Distributivos#

Aqui as coisas ficam interessantes, e é onde a maioria das pessoas se confunde. Quando você passa um union type para um tipo condicional, o TypeScript distribui a condição sobre cada membro da union individualmente.

typescript
type ToArray<T> = T extends unknown ? T[] : never;
 
type Result = ToArray<string | number>;
// Você pode esperar: (string | number)[]
// Resultado real:    string[] | number[]

O TypeScript não avalia (string | number) extends unknown. Em vez disso, ele avalia string extends unknown e number extends unknown separadamente, e então faz a union dos resultados.

É por isso que Extract e Exclude funcionam do jeito que funcionam. A distribuição acontece automaticamente quando T é um parâmetro de tipo "nu" (não envolvido em nada).

Se você quiser prevenir a distribuição, envolva ambos os lados em uma tupla:

typescript
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
 
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]

Já fui pego por isso mais vezes do que gostaria de admitir. Se seu tipo condicional está dando resultados inesperados com inputs de union, a distribuição é quase sempre a razão.

Exemplo Prático: Handler de Resposta de API#

Aqui está um padrão que uso ao lidar com respostas de API que podem ter sucesso ou falhar:

typescript
type ApiResponse<T> =
  | { status: "success"; data: T; error: never }
  | { status: "error"; data: never; error: string };
 
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
 
type UserResponse = ApiResponse<{ id: number; name: string }>;
 
type UserData = ExtractData<UserResponse>;
// Result: { id: number; name: string }
 
type UserError = ExtractError<UserResponse>;
// Result: string

Isso é mais limpo do que escrever type guards manuais para cada formato de resposta.

A Palavra-chave infer#

infer é pattern matching para tipos. Ela permite declarar uma variável de tipo dentro de um tipo condicional que o TypeScript vai descobrir pra você.

Pense assim: "Eu sei que esse tipo tem um certo formato. Extraia a parte que me interessa."

Extraindo Tipos de Retorno#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

O TypeScript vê o padrão (...args) => algo, compara sua função com ele e atribui o tipo de retorno a R.

Extraindo Tipos de Parâmetros#

typescript
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
 
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]

Desempacotando Promises#

Esse eu uso o tempo todo quando trabalho com funções async:

typescript
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
 
type A = UnpackPromise<Promise<string>>;  // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>;            // string (passthrough)

Para promises profundamente aninhadas:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: number

Extraindo Tipos de Elementos de Array#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

Múltiplos infer em Um Tipo#

Você pode usar infer múltiplas vezes para extrair partes diferentes:

typescript
type FirstAndLast<T extends unknown[]> =
  T extends [infer First, ...unknown[], infer Last]
    ? { first: First; last: Last }
    : never;
 
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Result: { first: 1; last: 4 }

Mundo Real: Extração de Eventos Type-Safe#

Aqui está um padrão que uso em sistemas orientados a eventos:

typescript
type EventMap = {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "order:created": { orderId: string; total: number };
  "order:shipped": { orderId: string; trackingId: string };
};
 
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
 
// Extract all user-related events
type UserEvents = Extract<EventName, `user:${string}`>;
// Result: "user:login" | "user:logout"
 
// Get the payload for user events only
type UserEventPayloads = EventPayload<UserEvents>;
// Result: { userId: string; timestamp: number } | { userId: string }

Mapped Types#

Mapped types permitem criar novos tipos de objeto transformando cada propriedade de um tipo existente. São o map() do sistema de tipos.

O Básico#

typescript
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
 
type Partial<T> = {
  [K in keyof T]?: T[K];
};

keyof T te dá uma union de todas as chaves de propriedade. [K in ...] itera sobre cada uma. T[K] é o tipo daquela propriedade.

Construindo Utility Types do Zero#

Required — torna todas as propriedades obrigatórias:

typescript
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

A sintaxe -? remove o modificador opcional. Da mesma forma, -readonly remove o readonly.

Pick — seleciona propriedades específicas:

typescript
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};
 
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}
 
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

Omit — remove propriedades específicas:

typescript
type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
};
 
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }

Record — cria um tipo de objeto com chaves e tipos de valor específicos:

typescript
type MyRecord<K extends string | number | symbol, V> = {
  [P in K]: V;
};
 
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }

Key Remapping com as#

O TypeScript 4.1 introduziu key remapping, que permite transformar nomes de propriedades:

typescript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
interface Person {
  name: string;
  age: number;
  email: string;
}
 
type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

Você também pode filtrar propriedades fazendo remapping para never:

typescript
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }

Isso é incrivelmente poderoso. Você está simultaneamente iterando sobre propriedades, filtrando-as e transformando seus nomes — tudo no nível de tipos.

Exemplo Prático: Tipos de Validação de Formulário#

Usei esse padrão em várias bibliotecas de formulário:

typescript
interface FormFields {
  username: string;
  email: string;
  age: number;
  bio: string;
}
 
type ValidationErrors<T> = {
  [K in keyof T]?: string[];
};
 
type TouchedFields<T> = {
  [K in keyof T]?: boolean;
};
 
type DirtyFields<T> = {
  [K in keyof T]?: boolean;
};
 
type FormState<T> = {
  values: T;
  errors: ValidationErrors<T>;
  touched: TouchedFields<T>;
  dirty: DirtyFields<T>;
  isValid: boolean;
  isSubmitting: boolean;
};
 
// Now your form state is fully typed based on the fields
type MyFormState = FormState<FormFields>;

Adicione um campo a FormFields, e todo tipo relacionado se atualiza automaticamente. Remova um campo, e o compilador captura toda referência. É esse tipo de coisa que previne categorias inteiras de bugs.

Template Literal Types#

Template literal types permitem manipular strings no nível de tipos. Eles usam a mesma sintaxe de backtick dos template literals do JavaScript, mas para tipos.

Manipulação Básica de Strings#

typescript
type Greeting = `Hello, ${string}`;
 
const a: Greeting = "Hello, world";    // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world";    // Error!

O TypeScript também fornece tipos nativos de manipulação de strings:

typescript
type Upper = Uppercase<"hello">;     // "HELLO"
type Lower = Lowercase<"HELLO">;     // "hello"
type Cap = Capitalize<"hello">;      // "Hello"
type Uncap = Uncapitalize<"Hello">;  // "hello"

Padrões de Nomes de Eventos#

É aqui que template literal types realmente brilham. Aqui está um padrão que uso para sistemas de eventos type-safe:

typescript
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
 
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"

Nove eventos a partir de dois parâmetros de tipo. E todos são type-safe.

Padrões CSS-in-TypeScript#

Template literal types podem impor padrões CSS no nível de tipos:

typescript
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
 
function setWidth(value: CSSValue): void {
  // implementation
}
 
setWidth("100px");   // OK
setWidth("2.5rem");  // OK
setWidth("50%");     // OK
// setWidth("100");  // Error: not a valid CSS value
// setWidth("big");  // Error

Parseando Strings com Template Literals#

Você pode usar template literal types com infer para parsear strings:

typescript
type ParseRoute<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ParseRoute<`/${Rest}`>
    : T extends `${infer _Start}:${infer Param}`
      ? Param
      : never;
 
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Result: "userId" | "postId"

Essa é a base de como bibliotecas de roteamento type-safe como o tRPC funcionam. A string /users/:userId/posts/:postId é parseada no nível de tipos para extrair nomes de parâmetros.

Combinando Template Literals com Mapped Types#

É aqui que as coisas ficam realmente poderosas:

typescript
type EventHandlers<T extends Record<string, unknown>> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (
    newValue: T[K],
    oldValue: T[K]
  ) => void;
};
 
interface ThemeConfig {
  color: string;
  fontSize: number;
  darkMode: boolean;
}
 
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
//   onColorChange: (newValue: string, oldValue: string) => void;
//   onFontSizeChange: (newValue: number, oldValue: number) => void;
//   onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }

A partir de uma simples interface de configuração, você obtém event handlers totalmente tipados com tipos de parâmetros corretos. É o tipo de código em nível de tipos que realmente economiza tempo de desenvolvimento.

Avançado: Tipos de Path com Notação de Ponto#

Aqui está um padrão mais avançado — gerando todos os caminhos possíveis com notação de ponto através de um objeto aninhado:

typescript
type DotPaths<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;
 
interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  server: {
    port: number;
  };
}
 
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"

Agora você pode escrever uma função getConfig que autocompleta todos os caminhos possíveis e retorna o tipo correto para cada um:

typescript
type GetValueByPath<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? GetValueByPath<T[Key], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;
 
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
  // implementation
  const keys = path.split(".");
  let result: unknown = config;
  for (const key of keys) {
    result = (result as Record<string, unknown>)[key];
  }
  return result as GetValueByPath<Config, P>;
}
 
const host = getConfig("database.host");         // type: string
const port = getConfig("database.port");         // type: number
const username = getConfig("database.credentials.username"); // type: string

Autocomplete completo, type safety total, zero overhead em tempo de execução dos tipos.

Tipos Recursivos#

O TypeScript suporta definições de tipos recursivos, o que permite lidar com estruturas aninhadas arbitrariamente.

Deep Partial#

O Partial<T> nativo só torna propriedades de primeiro nível opcionais. Para objetos aninhados, você precisa de recursão:

typescript
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? T[K] extends Array<infer U>
      ? Array<DeepPartial<U>>
      : DeepPartial<T[K]>
    : T[K];
};
 
interface Settings {
  theme: {
    colors: {
      primary: string;
      secondary: string;
    };
    fonts: {
      heading: string;
      body: string;
    };
  };
  notifications: {
    email: boolean;
    push: boolean;
  };
}
 
type PartialSettings = DeepPartial<Settings>;
// Every nested property is now optional

Note a verificação de array: sem ela, arrays seriam tratados como objetos e seus índices numéricos se tornariam opcionais, o que não é o que você quer.

Deep Readonly#

Mesmo padrão, modificador diferente:

typescript
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : T[K] extends Array<infer U>
        ? ReadonlyArray<DeepReadonly<U>>
        : DeepReadonly<T[K]>
    : T[K];
};

A verificação de Function é importante — sem ela, propriedades de função teriam readonly aplicado à sua estrutura interna, o que não faz sentido.

Tipo JSON#

Aqui está um tipo recursivo clássico — representando qualquer valor JSON válido:

typescript
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
 
function parseJson(input: string): JsonValue {
  return JSON.parse(input);
}
 
function isJsonObject(value: JsonValue): value is JsonObject {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

Isso é muito melhor do que usar any para parsing de JSON. Diz ao consumidor "você vai receber algo, mas precisa fazer narrowing antes de usar". O que é exatamente a verdade.

Tipo Recursivo para Menus Aninhados#

Um exemplo prático — tipando uma estrutura de menu de navegação:

typescript
interface MenuItem {
  label: string;
  href: string;
  icon?: string;
  children?: MenuItem[];
}
 
type Menu = MenuItem[];
 
const navigation: Menu = [
  {
    label: "Products",
    href: "/products",
    children: [
      {
        label: "Software",
        href: "/products/software",
        children: [
          { label: "IDE", href: "/products/software/ide" },
          { label: "CLI Tools", href: "/products/software/cli" },
        ],
      },
      { label: "Hardware", href: "/products/hardware" },
    ],
  },
  { label: "About", href: "/about" },
];

A propriedade recursiva children te dá aninhamento infinito com type safety total.

Padrões do Mundo Real#

Deixa eu compartilhar alguns padrões que realmente usei em sistemas de produção.

Padrão Builder com Tipos#

O padrão builder se torna significativamente mais útil quando o sistema de tipos rastreia o que foi definido:

typescript
type RequiredKeys = "host" | "port" | "database";
 
type BuilderState = {
  [K in RequiredKeys]?: true;
};
 
class DatabaseConfigBuilder<State extends BuilderState = {}> {
  private config: Partial<{
    host: string;
    port: number;
    database: string;
    ssl: boolean;
    poolSize: number;
  }> = {};
 
  setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
    this.config.host = host;
    return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
  }
 
  setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
    this.config.port = port;
    return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
  }
 
  setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
    this.config.database = db;
    return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
  }
 
  setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
    this.config.ssl = ssl;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  setPoolSize(size: number): DatabaseConfigBuilder<State> {
    this.config.poolSize = size;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  build(
    this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
  ): Required<Pick<typeof this.config, "host" | "port" | "database">> &
    Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
    return this.config as ReturnType<typeof this.build>;
  }
}
 
// This compiles:
const config = new DatabaseConfigBuilder()
  .setHost("localhost")
  .setPort(5432)
  .setDatabase("myapp")
  .setSsl(true)
  .build();
 
// This fails at compile time — missing .setDatabase():
// const bad = new DatabaseConfigBuilder()
//   .setHost("localhost")
//   .setPort(5432)
//   .build(); // Error: 'database' is missing

O método build() só se torna chamável quando todos os campos obrigatórios foram definidos. Isso é capturado em tempo de compilação, não em tempo de execução.

Event Emitter Type-Safe#

Aqui está um event emitter onde os tipos de payload são impostos:

typescript
type EventMap = Record<string, unknown>;
 
class TypedEventEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};
 
  on<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }
 
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach((listener) => listener(payload));
  }
 
  off<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    const handlers = this.listeners[event];
    if (handlers) {
      this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
    }
  }
}
 
// Usage
interface AppEvents {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "notification:new": { message: string; level: "info" | "warning" | "error" };
}
 
const emitter = new TypedEventEmitter<AppEvents>();
 
// Fully typed — IDE autocompletes event names and payload shapes
emitter.on("user:login", (payload) => {
  console.log(payload.userId);     // string
  console.log(payload.timestamp);  // number
});
 
// Type error: payload doesn't match
// emitter.emit("user:login", { userId: "123" });
// Error: missing 'timestamp'
 
// Type error: event doesn't exist
// emitter.on("user:signup", () => {});
// Error: "user:signup" is not in AppEvents

Usei esse padrão em três projetos de produção diferentes. Ele captura categorias inteiras de bugs relacionados a eventos em tempo de compilação.

Discriminated Unions com Verificação Exaustiva#

Discriminated unions são provavelmente o padrão mais útil do TypeScript. Combinados com verificação exaustiva, eles garantem que você trate todos os casos:

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      // This line ensures exhaustive checking
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Se alguém adicionar uma nova variante de forma (digamos "pentagon"), essa função vai falhar ao compilar porque never não pode receber um valor. O compilador te força a tratar todos os casos.

Levo isso mais adiante com uma função auxiliar:

typescript
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
  }
}

Máquinas de Estado Type-Safe#

Discriminated unions também modelam máquinas de estado lindamente:

typescript
type ConnectionState =
  | { status: "disconnected" }
  | { status: "connecting"; attempt: number }
  | { status: "connected"; socket: WebSocket; connectedAt: Date }
  | { status: "error"; error: Error; lastAttempt: Date };
 
type ConnectionAction =
  | { type: "CONNECT" }
  | { type: "CONNECTED"; socket: WebSocket }
  | { type: "DISCONNECT" }
  | { type: "ERROR"; error: Error }
  | { type: "RETRY" };
 
function connectionReducer(
  state: ConnectionState,
  action: ConnectionAction
): ConnectionState {
  switch (state.status) {
    case "disconnected":
      if (action.type === "CONNECT") {
        return { status: "connecting", attempt: 1 };
      }
      return state;
 
    case "connecting":
      if (action.type === "CONNECTED") {
        return {
          status: "connected",
          socket: action.socket,
          connectedAt: new Date(),
        };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "connected":
      if (action.type === "DISCONNECT") {
        state.socket.close();
        return { status: "disconnected" };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "error":
      if (action.type === "RETRY") {
        return { status: "connecting", attempt: 1 };
      }
      if (action.type === "DISCONNECT") {
        return { status: "disconnected" };
      }
      return state;
 
    default:
      assertNever(state, "Unknown connection state");
  }
}

Cada estado só tem as propriedades que fazem sentido para aquele estado. Você não pode acessar socket quando desconectado. Não pode acessar error quando conectado. O sistema de tipos impõe as restrições da máquina de estado.

Branded Types para Segurança de Domínio#

Mais um padrão que considero essencial — usar branded types para prevenir a mistura de valores que por acaso compartilham o mesmo tipo subjacente:

typescript
type Brand<T, B extends string> = T & { readonly __brand: B };
 
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
 
function createUserId(id: string): UserId {
  return id as UserId;
}
 
function createOrderId(id: string): OrderId {
  return id as OrderId;
}
 
function getUser(id: UserId): Promise<User> {
  // ...
}
 
function getOrder(id: OrderId): Promise<Order> {
  // ...
}
 
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
 
getUser(userId);   // OK
// getUser(orderId); // Error! OrderId is not assignable to UserId

Tanto UserId quanto OrderId são strings em tempo de execução. Mas em tempo de compilação, são tipos distintos. Você literalmente não pode passar um order ID onde um user ID é esperado. Isso capturou bugs reais em todo projeto onde usei.

Armadilhas Comuns#

Tipos avançados são poderosos, mas vêm com armadilhas. Eis o que aprendi da maneira difícil.

Limites de Referência Circular#

O TypeScript tem um limite de profundidade de recursão para tipos (atualmente em torno de 50 níveis, embora varie). Se você for fundo demais, vai receber o temido erro "Type instantiation is excessively deep and possibly infinite".

typescript
// This will hit the recursion limit for deeply nested objects
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
 
// Fix: Add a depth counter
type DeepPartialSafe<T, Depth extends number[] = []> =
  Depth["length"] extends 10
    ? T
    : {
        [K in keyof T]?: T[K] extends object
          ? DeepPartialSafe<T[K], [...Depth, 0]>
          : T[K];
      };

O truque do contador de profundidade usa uma tupla que cresce a cada passo recursivo. Quando atinge seu limite, a recursão para.

Implicações de Performance#

Tipos complexos podem diminuir significativamente a velocidade do seu editor e dos tempos de build. Já vi projetos onde um único tipo excessivamente esperto adicionava 3 segundos ao loop de feedback de cada tecla.

Sinais de alerta:

  • Seu IDE demora mais de 2 segundos para mostrar autocomplete
  • tsc --noEmit demora visivelmente mais depois de adicionar um tipo
  • Você vê erros "Type instantiation is excessively deep"
typescript
// This is too clever — it generates a combinatorial explosion
type AllCombinations<T extends string[]> = T extends [
  infer First extends string,
  ...infer Rest extends string[]
]
  ? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
  : "";
 
// Don't do this with more than 5-6 elements

Quando NÃO Usar Tipos Avançados#

Esta pode ser a seção mais importante. Tipos avançados devem ser usados quando previnem bugs ou melhoram a experiência do desenvolvedor. Não devem ser usados para se exibir.

Não use quando:

  • Um simples Record<string, unknown> bastaria
  • O tipo é usado em apenas um lugar e um tipo concreto seria mais claro
  • Você está gastando mais tempo debugando o tipo do que ele economizaria
  • Seu time não consegue ler ou manter
  • Uma verificação em tempo de execução seria mais apropriada

Use quando:

  • Você tem um padrão que se repete em muitos tipos (mapped types)
  • O tipo de retorno de uma função depende da entrada (tipos condicionais)
  • Você está construindo uma API de biblioteca que precisa ser autodocumentada
  • Você quer tornar estados ilegais irrepresentáveis
  • Você está cansado da mesma categoria de bug aparecendo em code reviews
typescript
// Over-engineered — don't do this for a simple config
type Config = DeepReadonly<
  DeepRequired<
    Merge<DefaultConfig, Partial<UserConfig>>
  >
>;
 
// Just do this
interface Config {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

Minha regra: se explicar o tipo demora mais do que explicar o bug que ele previne, simplifique.

Debugando Tipos Complexos#

Quando um tipo complexo não está funcionando, uso este helper para "ver" o que o TypeScript resolveu:

typescript
// Expands a type for inspection in IDE hover tooltips
type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};
 
// Use it to debug
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Now hover over Debug in your IDE to see the resolved type

O truque do & {} força o TypeScript a avaliar o tipo eagerly em vez de mostrar o alias do tipo. Me salvou horas de confusão.

Outra técnica — isolar e testar incrementalmente:

typescript
// Instead of debugging this all at once:
type Final = StepThree<StepTwo<StepOne<Input>>>;
 
// Break it apart:
type AfterStep1 = StepOne<Input>;       // hover to check
type AfterStep2 = StepTwo<AfterStep1>;  // hover to check
type AfterStep3 = StepThree<AfterStep2>; // hover to check

Resumo#

  • Tipos condicionais (T extends U ? X : Y) são if/else para tipos. Fique atento ao comportamento distributivo com unions.
  • infer é pattern matching — use para extrair tipos de estruturas como assinaturas de função, promises e arrays.
  • Mapped types ({ [K in keyof T]: ... }) iteram sobre propriedades. Combine com as para key remapping e filtragem.
  • Template literal types manipulam strings no nível de tipos. Combinados com mapped types, são incrivelmente poderosos para design de APIs.
  • Tipos recursivos lidam com estruturas aninhadas mas precisam de limites de profundidade para evitar explosões do compilador.
  • Discriminated unions com verificação exaustiva são o padrão de maior valor no TypeScript. Use-os em todo lugar.
  • Branded types previnem a mistura de valores que compartilham o mesmo tipo subjacente. Simples de implementar, alto impacto.
  • Não faça engenharia excessiva com tipos. Se o tipo é mais difícil de entender do que o bug que previne, simplifique. O objetivo é tornar sua codebase mais segura, não ganhar uma competição de type golf.

O sistema de tipos do TypeScript é Turing completo, o que significa que você pode fazer quase qualquer coisa com ele. A arte está em saber quando você deve.

Posts Relacionados