- Interface vs Type: qual usar e quando
- Tipando objetos do jeito certo
- Aninhados, opcionais e readonly
- Funções: parâmetros, retorno e sobrecarga
- Generics: tipagem flexível sem abrir mão da segurança
- Os erros que todo dev intermediário comete
- FAQ
- Próximos passos
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
| Erro | Por que acontece | Solução |
|---|---|---|
any em respostas de API | Preguiça de tipar JSON externo | zod com inferência, ou tipo manual com unknown na borda |
Esquecer undefined em opcionais | Tratar ? como "não obrigatório" | strictNullChecks: true + checagem explícita |
Tipar retorno como object | Parece genérico suficiente | Nunca é — crie uma interface com as propriedades reais |
Function como tipo de parâmetro | Parece aceitar qualquer função | (param: Tipo) => Retorno — explícito sempre |
Ignorar as const em literais | Não conhece o padrão | const roles = ["admin", "editor"] as const |
Usar ! (non-null assertion) livremente | Quer calar o compilador rápido | Tratar como unsafe — documentar ou remover |
any em catch (e) | Comportamento histórico do TS | unknown + 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 catch — unknown é 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.