Como Usar Constraints e Default Types em Generics TypeScript em Produção Já leu

Entendendo Generics em TypeScript Os generics são um dos recursos mais poderosos do TypeScript, permitindo que você escreva código reutilizável e type-safe. Imagine que você precisa criar uma função que funcione com qualquer tipo de dado, mas sem perder a segurança de tipos que o TypeScript oferece. É exatamente isso que os generics fazem. Eles funcionam como variáveis de tipo, permitindo que você parametrize tipos da mesma forma que parametriza valores em funções. A sintaxe básica usa a notação com colchetes angulares: . Essa letra é apenas uma convenção (de "Type"), mas você pode usar qualquer nome. Quando você define um generic, está dizendo: "eu vou trabalhar com algum tipo, mas você vai me dizer qual é no momento de usar". Isso oferece flexibilidade máxima sem sacrificar a segurança. O TypeScript infere o tipo automaticamente com base no argumento passado. Você também pode ser explícito se desejar: . Ambas as abordagens funcionam, mas a inferência automática torna o código mais

Entendendo Generics em TypeScript

Os generics são um dos recursos mais poderosos do TypeScript, permitindo que você escreva código reutilizável e type-safe. Imagine que você precisa criar uma função que funcione com qualquer tipo de dado, mas sem perder a segurança de tipos que o TypeScript oferece. É exatamente isso que os generics fazem. Eles funcionam como variáveis de tipo, permitindo que você parametrize tipos da mesma forma que parametriza valores em funções.

A sintaxe básica usa a notação com colchetes angulares: <T>. Essa letra T é apenas uma convenção (de "Type"), mas você pode usar qualquer nome. Quando você define um generic, está dizendo: "eu vou trabalhar com algum tipo, mas você vai me dizer qual é no momento de usar". Isso oferece flexibilidade máxima sem sacrificar a segurança.

// Função genérica básica
function identity<T>(value: T): T {
  return value;
}

// Usando a função
const numberResult = identity(42);        // T é number
const stringResult = identity("hello");   // T é string
const boolResult = identity(true);        // T é boolean

console.log(typeof numberResult); // "number"
console.log(typeof stringResult); // "string"

O TypeScript infere o tipo automaticamente com base no argumento passado. Você também pode ser explícito se desejar: identity<string>("hello"). Ambas as abordagens funcionam, mas a inferência automática torna o código mais limpo.

Constraints em Generics

Constraints (restrições) são mecanismos que limitam quais tipos podem ser usados com um generic. Sem constraints, você poderia passar qualquer tipo, o que às vezes não faz sentido. Por exemplo, se você quer trabalhar apenas com objetos que têm uma propriedade específica, precisa de um constraint.

A sintaxe de constraint usa extends: <T extends AlgumaCoisa>. Isso significa que T pode ser qualquer tipo, desde que seja compatível com AlgumaCoisa. Os constraints tornam seu código mais seguro e mais expressivo sobre suas intenções.

// Constraint básico: T deve ter a propriedade 'length'
function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

// Funciona com strings, arrays e objetos com length
console.log(getLength("hello"));        // 5
console.log(getLength([1, 2, 3]));      // 3
console.log(getLength({ length: 10 })); // 10

// Isso daria erro em tempo de compilação:
// getLength(42); // Error: Argument of type 'number' is not assignable

Constraints com Classes e Interfaces

Você pode usar classes e interfaces como constraints também. Isso é especialmente útil quando você quer garantir que o tipo possui métodos específicos ou implementa uma interface.

interface HasId {
  id: number;
}

function printId<T extends HasId>(obj: T): void {
  console.log(`ID: ${obj.id}`);
}

// Funciona
printId({ id: 1, name: "John" });
printId({ id: 2, email: "test@example.com" });

// Erro: 'id' não existe em number
// printId(123);

Constraints com Tipos Union e Keyof

TypeScript permite constraints mais sofisticados usando tipos union e a palavra-chave keyof. O keyof extrai todas as chaves de um tipo, criando um union delas.

// Constraint: T deve ser uma string ou number
function processValue<T extends string | number>(value: T): string {
  return `Valor: ${value}`;
}

console.log(processValue("hello")); // OK
console.log(processValue(42));      // OK
// processValue(true);              // Error

// Constraint com keyof: K deve ser uma chave de T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = { name: "Alice", age: 30 };
const name = getProperty(person, "name");  // OK, retorna string
const age = getProperty(person, "age");    // OK, retorna number
// getProperty(person, "email");           // Error: "email" não é chave

Default Types em Generics

Default types (tipos padrão) permitem que você especifique um tipo padrão quando nenhum é fornecido explicitamente. Isso é semelhante aos parâmetros padrão em funções, mas para tipos. Quando você não passa um tipo específico, o padrão é usado automaticamente.

A sintaxe é simples: <T = TipoPadrão>. Isso torna seus generics mais flexíveis, permitindo uso simples para casos comuns enquanto mantém a capacidade de especificar tipos diferentes quando necessário.

// Generic com tipo padrão
function create<T = string>(value?: T): T | undefined {
  return value;
}

// Sem argumento de tipo, usa string como padrão
const result1 = create();           // T é string, retorna undefined
const result2 = create("hello");    // T é string
const result3 = create<number>(42); // T explicitamente number
const result4 = create<boolean>(true); // T explicitamente boolean

console.log(result2); // "hello"
console.log(result3); // 42

Default Types em Interfaces e Types

Default types são especialmente úteis em interfaces e types, onde você define estruturas genéricas que serão reutilizadas em muitos lugares.

// Interface genérica com default type
interface ApiResponse<T = any> {
  status: number;
  data: T;
  message: string;
}

// Usar sem especificar T
const response1: ApiResponse = {
  status: 200,
  data: "anything",
  message: "Success"
};

// Usar com T específico
const response2: ApiResponse<{ id: number; name: string }> = {
  status: 200,
  data: { id: 1, name: "John" },
  message: "Success"
};

// Type genérico com default
type Container<T = string> = {
  value: T;
  isEmpty: boolean;
};

const stringContainer: Container = { value: "hello", isEmpty: false };
const numberContainer: Container<number> = { value: 42, isEmpty: false };

Combinando Constraints e Defaults

A combinação de constraints com default types é poderosa. O default deve ser compatível com o constraint, e o TypeScript validará isso.

// Generic com constraint e default type
interface Entity {
  id: number;
}

function processEntity<T extends Entity = { id: number; name: string }>(
  entity: T
): number {
  return entity.id;
}

// Usando o default
const result1 = processEntity({ id: 1, name: "John" });

// Especificando um tipo diferente (mas ainda compatível com Entity)
const result2 = processEntity<{ id: number; email: string }>({
  id: 2,
  email: "test@example.com"
});

console.log(result1); // 1
console.log(result2); // 2

Default Types em Múltiplos Generics

Quando você tem múltiplos parâmetros genéricos, cada um pode ter seu próprio default type. Isso oferece muita flexibilidade.

// Múltiplos generics com defaults
interface Paginated<T = any, P = number> {
  items: T[];
  pageNumber: P;
  totalPages: P;
}

// Usar todos os defaults
const page1: Paginated = {
  items: ["a", "b", "c"],
  pageNumber: 1,
  totalPages: 5
};

// Especificar apenas o primeiro
const page2: Paginated<{ id: number; title: string }> = {
  items: [{ id: 1, title: "Post 1" }],
  pageNumber: 1,
  totalPages: 10
};

// Especificar ambos
const page3: Paginated<string, string> = {
  items: ["item1", "item2"],
  pageNumber: "first",
  totalPages: "last"
};

Casos Práticos e Padrões Avançados

Na prática profissional, constraints e defaults trabalham juntos para criar APIs robustas e amigáveis. Vamos ver alguns padrões que você encontrará em código real.

Padrão: Generic Builders

interface Builder<T> {
  build(): T;
}

class ObjectBuilder<T extends Record<string, any> = { id: number }> {
  private obj: Partial<T> = {};

  set<K extends keyof T>(key: K, value: T[K]): this {
    this.obj[key] = value;
    return this;
  }

  build(): T {
    return this.obj as T;
  }
}

// Usar com tipo padrão
const builder1 = new ObjectBuilder()
  .set("id", 1)
  .build();

// Usar com tipo específico
interface User {
  id: number;
  name: string;
  email: string;
}

const builder2 = new ObjectBuilder<User>()
  .set("id", 1)
  .set("name", "Alice")
  .set("email", "alice@example.com")
  .build();

console.log(builder2); // { id: 1, name: "Alice", email: "alice@example.com" }

Padrão: Generic Storage com Validação

interface Validator<T> {
  validate(value: unknown): value is T;
}

class TypedStorage<T, V extends Validator<T> = Validator<T>> {
  private data: Map<string, T> = new Map();

  constructor(private validator: V) {}

  set(key: string, value: unknown): boolean {
    if (this.validator.validate(value)) {
      this.data.set(key, value);
      return true;
    }
    return false;
  }

  get(key: string): T | undefined {
    return this.data.get(key);
  }
}

// Validator para números
const numberValidator: Validator<number> = {
  validate: (value): value is number => typeof value === "number"
};

const numStorage = new TypedStorage(numberValidator);
numStorage.set("count", 42);      // true
numStorage.set("count", "hello"); // false
console.log(numStorage.get("count")); // 42

Padrão: Generic Utilities com Keyof

// Utilitário para filtrar e mapear objetos de forma type-safe
function pickProperties<T, K extends keyof T>(
  obj: T,
  ...keys: K[]
): Pick<T, K> {
  const result = {} as Pick<T, K>;
  keys.forEach(key => {
    result[key] = obj[key];
  });
  return result;
}

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
}

const product: Product = {
  id: 1,
  name: "Laptop",
  price: 999,
  description: "High-performance laptop"
};

// Pegar apenas id e name
const summary = pickProperties(product, "id", "name");
console.log(summary); // { id: 1, name: "Laptop" }

// TypeScript garante que você só pode pedir chaves válidas:
// pickProperties(product, "invalid"); // Error

Conclusão

Você aprendeu que constraints e defaults são ferramentas complementares que tornam seus generics mais seguros e úteis. Constraints garantem que você trabalhe apenas com tipos que fazem sentido para sua lógica, enquanto defaults oferecem convenção sensata para os casos mais comuns, reduzindo a necessidade de sempre especificar tipos explicitamente.

O segundo ponto importante é que a combinação desses dois recursos permite criar APIs genéricas que são simultaneamente flexíveis e seguras. Você consegue escrever código reutilizável que funciona com múltiplos tipos, mas sem perder a validação em tempo de compilação que torna o TypeScript valioso.

Por fim, entenda que esses padrões são encontrados em todas as bibliotecas TypeScript profissionais. Frameworks como NestJS, bibliotecas como Zod e padrões de design moderno usam extensivamente constraints e defaults. Dominando esses conceitos, você consegue não apenas usar essas ferramentas, mas também projetar suas próprias APIs genéricas de forma responsável.

Referências


Artigos relacionados