Tipagem TypeScript na Prática: Objetos, Funções e Generics Sem `any`
← Voltar para Codeshort

Tipagem TypeScript na Prática: Objetos, Funções e Generics Sem `any`

Guia técnico completo para tipar objetos aninhados, funções com sobrecarga e generics no TypeScript — com exemplos reais, armadilhas comuns e os padrões que separam código que dura de código que vira dívida.

DC
Dev Code Software
27 de maio de 2026·10 min de leitura

Tem um ponto específico na curva de aprendizado do TypeScript onde você percebe que está fazendo tudo errado — e que o projeto inteiro pode estar comprometido por isso.

Não é quando você coloca any em um objeto complexo "só pra resolver rápido". É quando você percebe que fez isso em vinte lugares diferentes, que outros devs copiaram o padrão, e que agora o TypeScript no projeto inteiro é decorativo. Você tem a sintaxe, tem o compilador, mas perdeu todas as garantias.

Este guia resolve isso de forma sistemática. Não é uma introdução ao TypeScript — é o que você precisa saber para tipar código de verdade: objetos que crescem, funções que aceitam múltiplos formatos, generics que não assustam, e os padrões que aparecem em todo codebase profissional.


Interface vs Type: qual usar e quando

A dúvida mais frequente de quem está consolidando o TypeScript. A resposta direta: interface para objetos de domínio, type para todo o resto.

Mas entender por que essa convenção existe é o que te faz tomar a decisão certa nos casos não óbvios.

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

interface User {
  email: string;
}

O código acima é válido. interface suporta declaration merging — redeclarar a mesma interface mescla as propriedades automaticamente. Isso parece uma má ideia até você precisar estender os tipos de uma biblioteca sem modificar o código-fonte dela. É exatamente como o arquivo global.d.ts do Next.js funciona para augmentar tipos do process.env.

type, por outro lado, não aceita redeclaração — mas é obrigatório para tudo que interface não consegue expressar:

type Status = "active" | "inactive" | "pending";

type ID = string | number;

type UserWithStatus = User & { status: Status };

type NonNullable<T> = T extends null | undefined ? never : T;

Unions, intersections, tipos condicionais e tipos mapeados precisam de type. Tentar forçar isso em interface não funciona.

Uma diferença prática que afeta projetos grandes: interface com extends é mais performático em tempo de compilação do que type com &. TypeScript cacheia o resultado de extends; intersections via & são reavaliadas a cada uso. Em uma base de código com centenas de tipos complexos, isso aparece no tempo do tsc.

Regra de bolso: Está modelando uma entidade de negócio (User, Product, Order)? interface. Está criando um tipo auxiliar, utilitário ou derivado? type.


Tipando objetos do jeito certo

O erro mais comum não é usar any — é criar interfaces vagas demais. Existe um espectro entre any e uma tipagem precisa, e a maioria do código que eu já revisei está no meio errado desse espectro.

const config: any = {
  host: "localhost",
  port: 5432,
  options: { ssl: true, timeout: 30 }
};

const config: object = {
  host: "localhost",
  port: 5432,
  options: { ssl: true, timeout: 30 }
};

const config: Record<string, unknown> = {
  host: "localhost",
  port: 5432,
  options: { ssl: true, timeout: 30 }
};

Os três exemplos acima têm o mesmo problema: você sabe exatamente o que está dentro do objeto e escolheu não expressar isso. O TypeScript não pode te ajudar se você não diz o que espera.

interface DatabaseConfig {
  host: string;
  port: number;
  options: {
    ssl: boolean;
    timeout: number;
    maxConnections?: number;
  };
}

const config: DatabaseConfig = {
  host: "localhost",
  port: 5432,
  options: {
    ssl: true,
    timeout: 30
  }
};

Agora o TypeScript sabe o que tem dentro. Se alguém passar port: "5432" como string, o erro aparece em tempo de desenvolvimento, não em produção.

Quando Record<K, V> faz sentido

Record é a escolha certa quando você genuinamente não sabe as chaves em tempo de compilação:

type FeatureFlags = Record<string, boolean>;

const flags: FeatureFlags = {
  newDashboard: true,
  betaCheckout: false,
  darkMode: true
};

Se você sabe as chaves, use um tipo literal:

type FeatureFlags = Record<"newDashboard" | "betaCheckout" | "darkMode", boolean>;

Agora o TypeScript garante que todas as flags estão presentes e que nenhuma chave inválida é aceita.


Aninhados, opcionais e readonly

Propriedades opcionais sem armadilhas

? em uma propriedade significa que ela pode ser undefined. Parece óbvio, mas a consequência prática pega muita gente desprevenida:

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

function applyDiscount(product: Product): number {
  return product.price * (1 - product.discount / 100);
}

Esse código não compila com strict: true. product.discount é number | undefined, e dividir undefined por 100 resulta em NaN em tempo de execução — exatamente o que o TypeScript está tentando evitar.

function applyDiscount(product: Product): number {
  if (product.discount === undefined || product.discount === 0) {
    return product.price;
  }
  return product.price * (1 - product.discount / 100);
}

A verificação explícita parece verbosa, mas ela documenta a intenção: desconto zero e desconto ausente são tratados da mesma forma aqui. Se a regra de negócio mudar, o código deixa claro onde ajustar.

Readonly e seus limites

interface AppConfig {
  readonly apiUrl: string;
  readonly version: string;
  featureFlags: Record<string, boolean>;
}

const config: AppConfig = {
  apiUrl: "https://api.devcodeweb.online",
  version: "2.1.0",
  featureFlags: { newDashboard: true }
};

config.apiUrl = "outro-url";
config.featureFlags.newDashboard = false;

A primeira atribuição falha na compilação. A segunda passa — e esse é o comportamento que surpreende.

readonly protege a referência, não o conteúdo. config.featureFlags não pode ser substituído por outro objeto, mas o objeto em si pode ser modificado livremente. Para imutabilidade profunda:

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

type ImmutableConfig = DeepReadonly<AppConfig>;

Ou, em projetos que já usam immer, o tipo Draft<T> resolve o problema de forma mais ergonômica para mutations intencionais.

Atualizações parciais com Partial

Um padrão recorrente em funções de update:

interface User {
  id: string;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
}

function updateUser(id: string, updates: Partial<Omit<User, "id">>): Promise<User> {
  return db.users.update(id, updates);
}

updateUser("123", { name: "Ana Silva" });
updateUser("123", { role: "admin", email: "ana@empresa.com" });

Partial<T> torna todas as propriedades opcionais. Omit<T, K> remove propriedades que não devem ser alteradas. Combinados, você descreve exatamente o que a função aceita sem criar um tipo separado.


Funções: parâmetros, retorno e sobrecarga

Retorno explícito como contrato

async function fetchUser(id: string) {
  return db.find(id);
}

O TypeScript infere o retorno aqui, mas você depende de db.find retornar o tipo certo. Se o tipo de db.find mudar, a mudança se propaga silenciosamente por toda função que usa fetchUser. Retorno explícito é documentação executável:

async function fetchUser(id: string): Promise<User | null> {
  const result = await db.find(id);
  return result ?? null;
}

Agora qualquer mudança que quebre esse contrato falha em compilação, não em runtime.

Funções como tipos

type FetchFn<T> = (id: string) => Promise<T | null>;
type TransformFn<In, Out> = (input: In) => Out;
type PredicateFn<T> = (item: T) => boolean;

const fetchUser: FetchFn<User> = async (id) => db.find(id) ?? null;
const fetchProduct: FetchFn<Product> = async (id) => db.products.find(id) ?? null;

Definir tipos de função separadamente permite reuso e torna assinaturas complexas legíveis.

Parâmetros como objetos tipados

function createUser(name: string, email: string, role: string, active: boolean, sendWelcome: boolean) {
  // ...
}

createUser("Ana", "ana@empresa.com", "admin", true, false);

Esse código tem um bug clássico esperando para acontecer: a ordem dos booleanos. Em uma code review, ninguém vai decorar que o quinto parâmetro é active e o sexto é sendWelcome. E refatorar a ordem quebra todos os callsites silenciosamente em JavaScript.

interface CreateUserParams {
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
  active?: boolean;
  sendWelcomeEmail?: boolean;
}

function createUser({
  name,
  email,
  role,
  active = true,
  sendWelcomeEmail = true
}: CreateUserParams): Promise<User> {
  // ...
}

createUser({
  name: "Ana",
  email: "ana@empresa.com",
  role: "admin",
  sendWelcomeEmail: false
});

Cada parâmetro é autodocumentado no callsite. Adicionar um novo parâmetro opcional não quebra nenhum código existente.

Sobrecarga de funções

Quando uma função aceita formatos diferentes de entrada e o tipo de retorno muda com a entrada:

function formatDate(date: Date): string;
function formatDate(timestamp: number): string;
function formatDate(iso: string): Date;
function formatDate(input: Date | number | string): string | Date {
  if (typeof input === "string") {
    return new Date(input);
  }
  const d = typeof input === "number" ? new Date(input) : input;
  return d.toISOString().split("T")[0];
}

const str = formatDate(new Date());
const date = formatDate("2026-01-15");

As primeiras três linhas são as assinaturas públicas — o que os consumidores da função enxergam. A quarta é a implementação real. TypeScript usa as assinaturas para inferir o tipo de retorno baseado no tipo do argumento.


Generics: tipagem flexível sem abrir mão da segurança

Generic é um parâmetro para o tipo — a mesma ideia que parâmetros para valores, mas operando no nível da tipagem. O exemplo clássico deixa isso claro:

function first(arr: any[]): any {
  return arr[0];
}

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const nome = first(["Ana", "Bruno"]);
const id = first([1, 2, 3]);
const flag = first([true, false]);

Com any, você perde a informação de tipo na saída — TypeScript não sabe que first(["Ana"]) retorna string. Com generic, TypeScript infere T = string quando o array é string[], e o retorno é string | undefined.

Constraints: restrindo o que o generic aceita

function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id);
}

findById(users, "u-123");
findById(products, "p-456");
findById([1, 2, 3], "1");

T extends { id: string } diz: "aceito qualquer tipo T, desde que T tenha uma propriedade id do tipo string". A última chamada falha em compilação porque number não tem id.

keyof com generics

Um dos padrões mais úteis para funções utilitárias:

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map(item => item[key]);
}

const names = pluck(users, "name");
const emails = pluck(users, "email");
const ids = pluck(users, "id");
const invalid = pluck(users, "nonexistent");

K extends keyof T garante que a chave existe em T. T[K] é o tipo do valor nessa chave — se key é "name" e User.name é string, o retorno é string[]. Se key é "id" e User.id é number, o retorno é number[]. Tudo inferido automaticamente.

Defaults em generics

interface PaginatedResponse<T, Meta = Record<string, never>> {
  data: T[];
  total: number;
  page: number;
  meta: Meta;
}

type UserList = PaginatedResponse<User>;
type UserListWithCursor = PaginatedResponse<User, { nextCursor: string }>;

O parâmetro Meta tem um default — se não especificado, assume Record<string, never> (objeto vazio). Isso evita criar dois tipos separados para o caso com e sem metadata.


Os erros que todo dev intermediário comete

ErroPor que aconteceSolução
any em respostas de APIPreguiça de tipar JSON externozod com inferência, ou tipo manual com unknown na borda
Esquecer undefined em opcionaisTratar ? como "não obrigatório"strictNullChecks: true + checagem explícita
Tipar retorno como objectParece genérico suficienteNunca é — crie uma interface com as propriedades reais
Function como tipo de parâmetroParece aceitar qualquer função(param: Tipo) => Retorno — explícito sempre
Ignorar as const em literaisNão conhece o padrãoconst roles = ["admin", "editor"] as const
Usar ! (non-null assertion) livrementeQuer calar o compilador rápidoTratar como unsafe — documentar ou remover
any em catch (e)Comportamento histórico do TSunknown + type guard ou instanceof Error

O as const merece atenção especial porque o impacto vai além do esperado:

const roles = ["admin", "editor", "viewer"];

const roles = ["admin", "editor", "viewer"] as const;
type Role = typeof roles[number];

Sem as const, roles é string[] e Role seria string. Com as const, roles é readonly ["admin", "editor", "viewer"] e Role é a union "admin" | "editor" | "viewer". A fonte da verdade fica em um só lugar — adicione um papel no array e o tipo é atualizado automaticamente.

O mesmo padrão para objetos de configuração:

const HTTP_STATUS = {
  OK: 200,
  CREATED: 201,
  NOT_FOUND: 404,
  INTERNAL_ERROR: 500
} as const;

type HttpStatus = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];

HttpStatus é 200 | 201 | 404 | 500 — não number. Isso significa que uma função que aceita HttpStatus rejeita 999 em compilação.


FAQ

Sempre devo tipar o retorno de funções explicitamente?

Para funções exportadas de módulos, métodos públicos de classes e qualquer função que faça parte de uma API interna — sim. O tipo de retorno explícito é o contrato da função. Para funções internas e callbacks simples, a inferência do TypeScript é suficiente e tipar explicitamente só adiciona ruído sem benefício. A heurística: se outra pessoa vai chamar a função, escreva o tipo de retorno.

Qual a diferença real entre interface extends e type &?

Funcionalmente equivalentes para a maioria dos casos, mas com diferenças em casos extremos. interface extends produz erros mais legíveis quando há conflito de propriedades — TypeScript avisa no ponto da declaração. Com type &, propriedades conflitantes se tornam never silenciosamente, o que só aparece quando você tenta usar o valor. Em bases de código grandes, interface extends também é mais performático porque TypeScript cacheia o resultado da checagem.

unknown vs any: quando usar cada um?

any desliga a checagem de tipos completamente — use com consciência e documente o motivo. unknown mantém a segurança: você não pode fazer nada com um valor unknown sem primeiro verificar o tipo. Para dados externos — respostas de API, JSON.parse, parâmetros de catchunknown é sempre a escolha certa. Para código legado que você está migrando incrementalmente, any pode ser uma etapa temporária, nunca um destino.

Como tipar funções que recebem callbacks?

type SuccessCallback<T> = (data: T) => void;
type ErrorCallback = (error: Error) => void;
type CompleteCallback = () => void;

function fetchWithCallbacks<T>(
  url: string,
  onSuccess: SuccessCallback<T>,
  onError: ErrorCallback,
  onComplete?: CompleteCallback
): void {
  fetch(url)
    .then(res => res.json())
    .then((data: T) => {
      onSuccess(data);
      onComplete?.();
    })
    .catch((err: unknown) => {
      onError(err instanceof Error ? err : new Error(String(err)));
      onComplete?.();
    });
}

Definir os tipos de callback separadamente melhora a legibilidade da assinatura e permite reusar os tipos em outros contextos.

Como tipar um objeto onde algumas chaves são conhecidas e outras são dinâmicas?

interface ApiResponse {
  status: number;
  message: string;
  data: Record<string, unknown>;
  [key: string]: unknown;
}

Index signatures permitem chaves dinâmicas enquanto mantém as propriedades conhecidas tipadas. Atenção: todas as propriedades conhecidas precisam ser compatíveis com o tipo do index signature — aqui, unknown cobre tudo.


Próximos passos

Se você aplicar três coisas deste guia, que sejam estas:

1. Ative strict: true no tsconfig.json

{
  "compilerOptions": {
    "strict": true
  }
}

Esse flag único ativa strictNullChecks, noImplicitAny, strictFunctionTypes e outros. É o TypeScript funcionando de verdade — sem ele, você está usando 40% da ferramenta.

2. Substitua any por unknown nas bordas do sistema

Toda chamada de API, todo JSON.parse, todo bloco catch é uma borda. Use unknown e escreva type guards ou use zod para validar antes de usar:

import { z } from "zod";

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email()
});

type User = z.infer<typeof UserSchema>;

async function fetchUser(id: string): Promise<User> {
  const data = await fetch(`/api/users/${id}`).then(r => r.json());
  return UserSchema.parse(data);
}

3. Use as const em arrays e objetos de configuração

Sempre que você tem um array ou objeto cujos valores são usados como tipos, as const elimina a necessidade de duplicar a informação.

O próximo nível natural depois desse guia são os utility types nativos: Partial<T>, Required<T>, Pick<T, K>, Omit<T, K>, ReturnType<F>, Parameters<F>, Awaited<T>. Eles eliminam duplicação de tipos e são a diferença entre uma tipagem que você mantém atualizada e uma que você abandona quando o projeto cresce.