Como Usar Conditional Types em TypeScript: infer e Tipos Dependentes em Produção Já leu

Entendendo Conditional Types em TypeScript Conditional Types são um recurso avançado do TypeScript que permite que você crie tipos que se comportam de forma diferente dependendo de certas condições. Em essência, você está criando uma lógica de decisão no nível de tipos, similar às estruturas que você usa em JavaScript, mas operando sobre tipos durante a compilação. A sintaxe básica segue o padrão: . Isso significa: "Se o tipo T é compatível com (extends) o tipo U, então o tipo resultante é X, caso contrário, é Y". Isso pode parecer simples à primeira vista, mas abre possibilidades extraordinárias para criar sistemas de tipos mais flexíveis e inteligentes. Por que Conditional Types Importam Sem Conditional Types, você frequentemente precisaria criar múltiplas assinaturas de função ou definir tipos genéricos muito amplos, perdendo precisão. Com eles, você consegue criar tipos que se adaptam ao contexto, fornecendo melhor autocompletar, detecção de erros em tempo de compilação e uma experiência de desenvolvedor significativamente melhor. O

Entendendo Conditional Types em TypeScript

Conditional Types são um recurso avançado do TypeScript que permite que você crie tipos que se comportam de forma diferente dependendo de certas condições. Em essência, você está criando uma lógica de decisão no nível de tipos, similar às estruturas if/else que você usa em JavaScript, mas operando sobre tipos durante a compilação.

A sintaxe básica segue o padrão: T extends U ? X : Y. Isso significa: "Se o tipo T é compatível com (extends) o tipo U, então o tipo resultante é X, caso contrário, é Y". Isso pode parecer simples à primeira vista, mas abre possibilidades extraordinárias para criar sistemas de tipos mais flexíveis e inteligentes.

Por que Conditional Types Importam

Sem Conditional Types, você frequentemente precisaria criar múltiplas assinaturas de função ou definir tipos genéricos muito amplos, perdendo precisão. Com eles, você consegue criar tipos que se adaptam ao contexto, fornecendo melhor autocompletar, detecção de erros em tempo de compilação e uma experiência de desenvolvedor significativamente melhor.

// Sem Conditional Types - impreciso
type GetPropertyType = string | number | boolean;

// Com Conditional Types - preciso
type GetPropertyType<T> = T extends string ? string
  : T extends number ? number
  : T extends boolean ? boolean
  : never;

const result1: GetPropertyType<"hello"> = "world"; // ✓ string
const result2: GetPropertyType<42> = 100; // ✓ number
const result3: GetPropertyType<true> = false; // ✓ boolean

O Operador infer e sua Poderosa Flexibilidade

O operador infer é uma ferramenta que funciona dentro de Conditional Types para "capturar" ou "extrair" tipos de posições desconhecidas. Quando você usa infer, você está dizendo ao TypeScript: "Descubra qual é esse tipo para mim". É como colocar um placeholder em um padrão e deixar o TypeScript preenchê-lo automaticamente.

O infer só pode ser usado dentro da condição de um Conditional Type (a parte extends). Você o coloca onde espera encontrar um tipo específico, e depois pode referenciá-lo na parte true ou false do condicional.

Extraindo Tipos de Genéricos

Um dos usos mais práticos do infer é extrair o tipo genérico de outra estrutura. Por exemplo, você pode querer descobrir qual é o tipo dentro de um Array<T> ou dentro de uma Promise<T>.

// Extrair o tipo dentro de um Array
type Flatten<T> = T extends Array<infer U> ? U : T;

type Str = Flatten<string[]>; // string
type Num = Flatten<number>; // number
type Nested = Flatten<Array<Array<string>>>; // Array<string>

// Extrair o tipo resolvido de uma Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;

type ResolvedString = Awaited<Promise<string>>; // string
type PlainNumber = Awaited<number>; // number

// Extrair tipos de função
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function greet(name: string): string {
  return `Hello, ${name}!`;
}

type GreetReturn = ReturnType<typeof greet>; // string

Infer com Múltiplas Posições

Você pode usar infer várias vezes no mesmo padrão para capturar diferentes partes de um tipo. Isso é especialmente útil quando você precisa desconstruir tipos complexos.

// Capturar primeiro e último elemento de um array
type First<T> = T extends [infer F, ...any[]] ? F : never;
type Last<T> = T extends [...any[], infer L] ? L : never;

type FirstOfTuple = First<[string, number, boolean]>; // string
type LastOfTuple = Last<[string, number, boolean]>; // boolean

// Extrair argumentos e retorno de uma função
type FunctionSignature<T> = T extends (...args: infer A) => infer R 
  ? { args: A; returnType: R } 
  : never;

type MyFunc = (x: number, y: string) => boolean;
type Signature = FunctionSignature<MyFunc>;
// { args: [number, string]; returnType: boolean }

Tipos Dependentes: Criando Sistemas de Tipos Responsivos

Tipos dependentes são tipos cujo comportamento depende do valor real (não apenas do tipo) dos parâmetros genéricos. Enquanto o TypeScript não é uma linguagem totalmente dependente de tipos como Idris ou Lean, você pode simular esse comportamento usando Conditional Types de forma criativa, criando tipos que se comportam diferentemente com base em propriedades específicas.

Tipos Que Reagem a Propriedades

A ideia central é criar tipos que inspecionam suas entradas e se adaptam. Imagine um tipo que precisa se comportar diferentemente se recebe um objeto com certas propriedades, ou um tipo que precisa garantir que certos campos sejam preenchidos baseado em outro campo.

// Tipo que depende de uma propriedade específica
type GetUserEmail<T extends { type: string }> = 
  T extends { type: "admin" } ? string :
  T extends { type: "guest" } ? string | null :
  never;

type AdminEmail = GetUserEmail<{ type: "admin" }>; // string
type GuestEmail = GetUserEmail<{ type: "guest" }>; // string | null

// Tipo mais complexo: validação condicional
type ValidateUser<T> = 
  T extends { age: infer Age } 
    ? Age extends number 
      ? Age >= 18 
        ? T 
        : { error: "User must be 18 or older" }
      : { error: "Age must be a number" }
    : { error: "User must have an age property" };

type ValidUser = ValidateUser<{ age: 25 }>; // { age: 25 }
type InvalidUser = ValidateUser<{ age: 15 }>; // { error: "User must be 18 or older" }
type MissingAge = ValidateUser<{ name: "John" }>; // { error: "User must have an age property" }

Tipos Distributivos em Ação

Quando você passa uma union type a um Conditional Type, o TypeScript aplica a condição a cada membro da union separadamente. Esse comportamento é chamado de distribuição e é extremamente útil para processar múltiplos tipos.

// Sem distribuição (acidental)
type NonDistributive<T> = [T] extends [string] ? true : false;

type Test1 = NonDistributive<string | number>; // false

// Com distribuição (padrão para Conditional Types)
type Distributive<T> = T extends string ? true : false;

type Test2 = Distributive<string | number>; // true | false
type Test3 = Distributive<string>; // true

// Extrair apenas tipos string de uma union
type ExtractStrings<T> = T extends string ? T : never;

type Mixed = ExtractStrings<string | number | boolean>; // string
type MultiString = ExtractStrings<"admin" | "user" | 42 | "guest">; // "admin" | "user" | "guest"

// Filtrar tipos opcionais de um objeto
type RemoveOptional<T> = {
  [K in keyof T]-?: T[K]
};

interface User {
  id: number;
  name?: string;
  email?: string;
}

type RequiredUser = RemoveOptional<User>;
// { id: number; name: string; email: string }

Casos de Uso Práticos e Aplicações Reais

Conditional Types e infer não são apenas abstrações matemáticas — eles resolvem problemas reais que desenvolvedores enfrentam todos os dias. Vamos explorar situações práticas onde esses conceitos brilham.

Garantir Segurança de Tipos em APIs Genéricas

Um dos cenários mais comuns é trabalhar com bibliotecas ou APIs genéricas que precisam se adaptar a diferentes tipos de entrada. Considere um cliente HTTP que precisa validar respostas com base no endpoint.

// Definir mapeamento de endpoints para seus tipos de resposta
type ApiEndpoints = {
  "/users": { id: number; name: string }[];
  "/posts": { id: number; title: string; content: string }[];
  "/profile": { id: number; email: string; role: "admin" | "user" };
};

// Extrair o tipo de resposta para um endpoint específico
type ApiResponse<T extends keyof ApiEndpoints> = ApiEndpoints[T];

// Função tipada que garante o tipo de retorno correto
async function fetchApi<Endpoint extends keyof ApiEndpoints>(
  url: Endpoint
): Promise<ApiResponse<Endpoint>> {
  const response = await fetch(url);
  return response.json();
}

// Uso com autocompletar e validação
const users = await fetchApi("/users"); // type: { id: number; name: string }[]
const profile = await fetchApi("/profile"); // type: { id: number; email: string; role: "admin" | "user" }

// Isso causaria erro de compilação:
// const invalid = await fetchApi("/invalid"); // ❌ Type error

Builders com Tipos Dependentes

Padrões builder são perfeitos para Conditional Types, pois precisam acompanhar o estado do que foi construído.

// Estados possíveis
type BuilderState = "empty" | "partial" | "complete";

// Objeto que rastreia o progresso
interface QueryBuilder {
  _state: BuilderState;
  _query: string;
}

// Tipo que controla qual método pode ser chamado
type CanExecute<T extends QueryBuilder> = T["_state"] extends "complete" 
  ? true 
  : false;

class QueryBuilderImpl implements QueryBuilder {
  _state: BuilderState = "empty";
  _query = "";

  select(fields: string): this {
    this._query += `SELECT ${fields} `;
    this._state = "partial";
    return this;
  }

  from(table: string): this {
    this._query += `FROM ${table} `;
    this._state = "complete";
    return this;
  }

  execute(this: QueryBuilder & { _state: "complete" }): string {
    return this._query.trim();
  }
}

const builder = new QueryBuilderImpl();
builder.select("id, name").from("users");
const result = builder.execute(); // ✓ Válido
// Chamar execute() sem antes fazer select() e from() causaria erro

Utilitários de Transformação de Tipos

Criar funções de transformação de tipos é onde Conditional Types realmente brilham. Esses utilitários fazem o trabalho pesado de manipulação de tipos.

// DeepReadonly: torna um tipo profundamente imutável
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

interface Config {
  database: {
    host: string;
    port: number;
  };
  cache: {
    enabled: boolean;
  };
}

type ReadonlyConfig = DeepReadonly<Config>;
// database.host não pode ser modificado, assim como qualquer propriedade aninhada

// PickByType: selecionar propriedades de um objeto por seu tipo
type PickByType<T, U> = {
  [K in keyof T as T[K] extends U ? K : never]: T[K];
};

interface Mixed {
  a: string;
  b: number;
  c: string;
  d: boolean;
}

type StringProps = PickByType<Mixed, string>; // { a: string; c: string }
type NumberProps = PickByType<Mixed, number>; // { b: number }

// OmitByType: remover propriedades de um objeto por seu tipo
type OmitByType<T, U> = {
  [K in keyof T as T[K] extends U ? never : K]: T[K];
};

type NonStringProps = OmitByType<Mixed, string>; // { b: number; d: boolean }

Conclusão

Ao longo deste artigo, você aprendeu que Conditional Types são a chave para criar sistemas de tipos responsivos: eles permitem que seus tipos se adaptem ao contexto e forneçam erros em tempo de compilação em vez de em tempo de execução. O operador infer é a ferramenta que torna isso possível, permitindo capturar e extrair tipos de estruturas complexas de forma elegante.

O segundo aprendizado central é que tipos dependentes transformam você de um mero consumidor de tipos em um arquiteto de sistemas de tipos. Com o conhecimento de Conditional Types, você pode criar APIs mais seguras, builders mais inteligentes e utilitários de transformação que elevam toda a experiência de desenvolvimento. A distribuição automática de unions e a capacidade de inspecionar tipos em profundidade abrem portas que antes pareciam fechadas.

Por fim, entenda que na prática, esses conceitos não são acadêmicos — eles resolvem problemas reais de segurança e manutenibilidade. Grandes projetos e bibliotecas (como lodash-es, axios, React Query) usam extensivamente Conditional Types para fornecer inferência precisa de tipos. Dominar isso não é um nice-to-have, é uma habilidade que diferencia desenvolvedores TypeScript competentes daqueles que verdadeiramente entendem o ecossistema.

Referências


Artigos relacionados