Conditional types: tipos que dependem de outros tipos

1. Introdução aos Conditional Types

Conditional types são uma feature poderosa do TypeScript que permite criar tipos que dependem de uma condição lógica avaliada em tempo de compilação. Eles funcionam como um operador ternário para tipos: se uma condição for verdadeira, o tipo resultante é um; caso contrário, é outro.

A sintaxe básica é:

T extends U ? X : Y

Isso significa: "se T for atribuível a U, então o tipo é X; caso contrário, é Y".

A diferença fundamental entre conditional types e tipos genéricos simples é que os genéricos apenas parametrizam tipos, enquanto os condicionais permitem tomar decisões baseadas na estrutura dos tipos envolvidos.

Exemplo prático inicial:

type IsString<T> = T extends string ? true : false;

type Test1 = IsString<"hello">;   // true
type Test2 = IsString<42>;        // false
type Test3 = IsString<string>;    // true

2. Funcionamento Interno e Distribuição

Quando usamos conditional types com tipos genéricos, o TypeScript avalia a condição para cada membro de uma união separadamente. Esse comportamento é chamado de distribuição.

type ToArray<T> = T extends any ? T[] : never;

type Result = ToArray<string | number>;
// Result: string[] | number[]

O TypeScript distribui a condição para cada elemento da união: primeiro avalia string extends any (verdadeiro, produz string[]), depois number extends any (verdadeiro, produz number[]), e então une os resultados.

Para desativar a distribuição, envolvemos ambos os lados da condição com colchetes:

type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type ResultNonDist = ToArrayNonDist<string | number>;
// Result: (string | number)[]

Aqui, [string | number] extends [any] é avaliado como uma única condição, preservando a união.

3. Inferindo Tipos com infer

A palavra-chave infer permite extrair tipos de dentro de outros tipos dentro de um conditional type. É como "desestruturar" tipos.

type Flatten<T> = T extends Array<infer U> ? U : T;

type StringArray = Flatten<string[]>;  // string
type NumberValue = Flatten<number>;    // number

O TypeScript já fornece utilitários built-in que usam infer:

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;

function greet(name: string, age: number): string {
  return `${name} tem ${age} anos`;
}

type GreetReturn = MyReturnType<typeof greet>;  // string
type GreetParams = MyParameters<typeof greet>;  // [string, number]

Exemplo avançado: extraindo o tipo de uma Promise:

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type AsyncNumber = UnwrapPromise<Promise<number>>;  // number
type SyncString = UnwrapPromise<string>;            // string

// Funciona recursivamente para Promises aninhadas
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;

type Nested = DeepUnwrap<Promise<Promise<Promise<boolean>>>>;  // boolean

4. Conditional Types Aninhados e Encadeados

Podemos encadear múltiplas condições para criar pipelines de transformação de tipos:

type TypeName<T> = 
  T extends string ? "string" :
  T extends number ? "number" :
  T extends boolean ? "boolean" :
  T extends undefined ? "undefined" :
  T extends null ? "null" :
  T extends Function ? "function" :
  "object";

type Name1 = TypeName<string>;       // "string"
type Name2 = TypeName<() => void>;   // "function"
type Name3 = TypeName<Date>;         // "object"

Exemplo mais complexo: DeepReadonly que torna todas as propriedades aninhadas readonly:

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

interface User {
  name: string;
  address: {
    street: string;
    city: string;
  };
}

type ReadonlyUser = DeepReadonly<User>;
// {
//   readonly name: string;
//   readonly address: {
//     readonly street: string;
//     readonly city: string;
//   };
// }

Cuidado com a complexidade: tipos muito aninhados podem prejudicar a legibilidade e a performance do compilador.

5. Aplicações Práticas: Filtragem e Transformação de Uniões

O TypeScript fornece utilitários built-in que usam conditional types para filtrar uniões:

// Extract: extrai tipos que estendem um tipo específico
type OnlyStrings = Extract<string | number | boolean, string>;  // string

// Exclude: remove tipos que estendem um tipo específico
type WithoutNumber = Exclude<string | number | boolean, number>;  // string | boolean

// Implementação manual de NonNullable
type MyNonNullable<T> = T extends null | undefined ? never : T;
type NotNull = MyNonNullable<string | null | undefined>;  // string

Exemplo avançado: filtro personalizado por tipo:

type FilterByType<T, U> = T extends U ? T : never;

type NumbersOnly = FilterByType<string | number | boolean, number>;  // number

// Caso de uso: tipagem segura de handlers
type EventHandler<T> = T extends "click" 
  ? (e: MouseEvent) => void 
  : T extends "keydown" 
    ? (e: KeyboardEvent) => void 
    : (e: Event) => void;

type ClickHandler = EventHandler<"click">;  // (e: MouseEvent) => void

6. Conditional Types com Mapped Types

Combinar keyof com conditional types permite filtrar chaves de objetos baseado no tipo de seus valores:

type PickByType<T, U> = {
  [K in keyof T as T[K] extends U ? K : never]: T[K];
};

type OmitByType<T, U> = {
  [K in keyof T as T[K] extends U ? never : K]: T[K];
};

interface Document {
  title: string;
  content: string;
  createdAt: Date;
  updatedAt: Date;
  version: number;
}

type StringFields = PickByType<Document, string>;
// { title: string; content: string; }

type DateFields = PickByType<Document, Date>;
// { createdAt: Date; updatedAt: Date; }

Identificando chaves opcionais e obrigatórias:

type OptionalKeys<T> = {
  [K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];

type RequiredKeys<T> = {
  [K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];

interface Config {
  name: string;
  age?: number;
  email?: string;
}

type Optional = OptionalKeys<Config>;  // "age" | "email"
type Required = RequiredKeys<Config>;  // "name"

7. Limitações e Boas Práticas

Conditional types têm limitações importantes:

  1. Tipos circulares: referências circulares podem causar erros de compilação ou loops infinitos.
  2. Performance: tipos muito complexos ou profundamente aninhados podem tornar a compilação lenta.
  3. Legibilidade: encadeamentos muito longos de condicionais são difíceis de entender.

Boas práticas:

  • Use nomes descritivos para tipos auxiliares
  • Documente tipos complexos com comentários
  • Prefira tipos built-in (Extract, Exclude, ReturnType) quando possível
  • Considere overloads de função como alternativa para casos simples
// Ruim: condicional complexo e sem nome
type Result = T extends string 
  ? (U extends number ? string[] : never) 
  : (U extends boolean ? number[] : never);

// Bom: tipos auxiliares nomeados
type StringHandler<U> = U extends number ? string[] : never;
type NumberHandler<U> = U extends boolean ? number[] : never;
type GoodResult<T, U> = T extends string ? StringHandler<U> : NumberHandler<U>;

Conditional types são uma ferramenta essencial para criar sistemas de tipos expressivos e seguros em TypeScript. Quando usados com moderação e boas práticas, eles permitem modelar relações complexas entre tipos de forma elegante e type-safe.

Referências