Types
O TypeScript tem duas construções para descrever a forma de um valor: a interface (contrato de objeto) e o type alias (apelido de tipo). Cada uma tem o seu lugar natural, e trocar as duas compila do mesmo jeito. O que se perde é a consistência: quem lê o código passa a não conseguir prever qual das duas vai encontrar.
Acima das duas está o structural typing (tipagem estrutural), a regra que decide se um valor cabe em um tipo. O TypeScript compara o formato: se o objeto tem os campos que o tipo pede, com os tipos que o tipo pede, ele serve. O nome que foi dado ao tipo não participa dessa decisão.
Conceitos fundamentais
| Conceito | O que é |
|---|---|
| interface (contrato de objeto) | Forma de objeto extensível via extends e implementável via implements |
| type alias (apelido de tipo) | type X = ...: apelido para union, intersection, mapped, primitivo ou shape |
| structural typing (tipagem estrutural) | Compatibilidade decidida pelo formato; dois tipos com os mesmos campos são intercambiáveis |
| union (união) | `A |
| intersection (interseção) | A & B: valor que satisfaz ambos os tipos simultaneamente |
| literal type (tipo literal) | Valor exato como tipo ("active", 42); usado em discriminated unions |
| utility type (tipo utilitário) | Partial, Pick, Omit, Record: derivam tipos de outros sem repetição |
| branded type (tipo marcado) | Primitivo + tag de tipo para distinguir valores semânticos (UserId, Email) |
Quando usar interface e quando usar type
interface descreve o formato de um objeto. Ela aceita extends e implements, e é a escolha
para contratos: o que um objeto de domínio tem, o que um repositório oferece, o que um serviço
expõe.
type descreve qualquer outra coisa que a interface não alcança: union, intersection, apelido de
um primitivo, tipo derivado de outro tipo. Um union escrito como interface nem compila, e é essa
a linha que separa as duas na prática.
❌ Ruim: type para um objeto simples, e interface onde só type funciona
// type para shape de objeto: funciona, mas não é a convenção
type User = {
id: string;
name: string;
};
// interface para union type: não compila
interface OrderStatus = "pending" | "approved"; // erro de sintaxe
✅ Bom: interface para objetos e contratos
interface User {
id: string;
name: string;
email: string;
}
interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
interface UserService extends EventEmitter {
findById(id: string): Promise<User>;
}
✅ Bom: type para uniões, intersections e aliases
type OrderStatus = "pending" | "approved" | "cancelled" | "shipped";
type UserId = string;
type Timestamp = number;
type AdminUser = User & { permissions: string[] };
type ApiResponse<T> = { data: T; meta: ResponseMeta };
Genéricos
Um genérico se justifica quando o formato do tipo muda de acordo com o parâmetro recebido.
PaginatedResult<TItem> é o caso: os campos total e hasNextPage são sempre iguais, e items
muda conforme quem usa. Quando o parâmetro de tipo não aparece em campo nenhum, ele é uma peça a
mais na assinatura sem efeito no resultado.
❌ Ruim: o parâmetro de tipo não aparece em nenhum campo
interface Response<T> {
success: boolean;
message: string; // T nunca aparece: o genérico não serve para nada aqui
}
✅ Bom: o parâmetro de tipo decide o formato de um campo
interface ApiResponse<TData> {
data: TData;
meta: {
total: number;
page: number;
};
}
interface PaginatedResult<TItem> {
items: TItem[];
total: number;
hasNextPage: boolean;
}
// uso
async function listOrders(): Promise<PaginatedResult<Order>> { /* ... */ }
async function listUsers(): Promise<PaginatedResult<User>> { /* ... */ }
Derive o tipo a partir do que já existe
UserDTO copiado à mão a partir de User fica correto no dia em que foi escrito. O problema chega
depois: quem acrescenta um campo em User precisa lembrar de acrescentar nos outros três tipos que
copiaram os campos dele, e o compilador não lembra por ninguém.
Os utility types (tipos utilitários, como Partial, Pick, Omit e Record) escrevem essa
relação em código. Omit<User, "password"> é lido como "o usuário, sem a senha", e ele acompanha o
User sozinho: campo novo em User aparece no DTO no mesmo instante.
❌ Ruim: o tipo é copiado à mão e sai de sincronia com o original
interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: string;
}
interface UserDTO { // duplica User sem password
id: string;
name: string;
email: string;
createdAt: string;
}
interface UpdateUserInput { // duplica User com todos os campos opcionais
name?: string;
email?: string;
password?: string;
}
✅ Bom: derivar a partir do tipo base
interface User {
id: string;
name: string;
email: string;
password: string;
createdAt: string;
}
type UserDTO = Omit<User, "password">;
type UpdateUserInput = Partial<Pick<User, "name" | "email" | "password">>;
A união discriminada modela o valor que tem formas diferentes
Um PaymentResult com todos os campos opcionais descreve estados que não existem. O tipo aceita um
resultado com success: true e errorCode preenchido ao mesmo tempo, e aceita um objeto vazio. Do
outro lado, dentro do if (result.success), o compilador continua dizendo que transactionId pode
ser undefined, porque nada no tipo liga um campo ao outro.
A união discriminada declara os estados que existem de verdade, um por interface, cada um com um
campo literal que o identifica (status: "success"). O compilador passa a conhecer a ligação:
depois de checar result.status === "success", transactionId é uma string, e os campos de erro
não estão disponíveis ali.
❌ Ruim: campos opcionais soltos, e o tipo aceita combinações impossíveis
interface PaymentResult {
success?: boolean;
transactionId?: string; // só existe quando success é true
errorCode?: string; // só existe quando success é false
errorMessage?: string;
}
function handlePayment(result: PaymentResult) {
if (result.success) {
console.log(result.transactionId); // string | undefined: TypeScript não garante
}
}
✅ Bom: o campo discriminante diz qual é o estado, e o compilador acompanha
interface PaymentSuccess {
status: "success";
transactionId: string;
}
interface PaymentFailure {
status: "failure";
errorCode: string;
errorMessage: string;
}
type PaymentResult = PaymentSuccess | PaymentFailure;
function handlePayment(result: PaymentResult) {
if (result.status === "success") {
console.log(result.transactionId); // string: TypeScript garante
return;
}
console.log(result.errorMessage); // string: narrowado para PaymentFailure
}
A intersection soma dois tipos sem criar hierarquia
Auditoria e exclusão lógica são preocupações que aparecem em várias entidades e não têm relação entre si. Resolver isso com herança obriga a inventar uma classe base que carrega as duas, e toda entidade que precisa de uma acaba herdando a outra.
A intersection (interseção, escrita com &) soma os campos sem hierarquia nenhuma.
BaseOrder & Auditable & SoftDeletable é lido como a soma das três partes, cada uma declarada
separada e reaproveitável onde fizer sentido.
❌ Ruim: os campos de cada parte são copiados dentro da entidade
interface Auditable {
createdAt: string;
updatedAt: string;
createdBy: string;
}
interface SoftDeletable {
deletedAt: string | null;
}
interface Order {
id: string;
customerId: string;
total: number;
// campos de Auditable duplicados manualmente
createdAt: string;
updatedAt: string;
createdBy: string;
// campos de SoftDeletable duplicados manualmente
deletedAt: string | null;
}
✅ Bom: cada parte é declarada uma vez, e a entidade soma as três
interface Auditable {
createdAt: string;
updatedAt: string;
createdBy: string;
}
interface SoftDeletable {
deletedAt: string | null;
}
type Order = BaseOrder & Auditable & SoftDeletable;
O as desliga a checagem no ponto em que ela era necessária
as User é uma afirmação sua, e o compilador aceita sem conferir nada. Se fetchUser devolver
null, o tipo continua dizendo User, e a linha que lê user.name quebra em runtime. Com
JSON.parse, que devolve any, é pior: qualquer formato passa pelo as AppConfig, inclusive um
JSON de outra versão da aplicação.
O as costuma aparecer no lugar exato onde o dado veio de fora e ninguém sabe o que ele é, que é
onde a checagem mais valia. Nesses pontos, cabe estreitar o tipo com um if de verdade, ou validar
o dado com um schema.
❌ Ruim: o as afirma um tipo que ninguém conferiu
const user = await fetchUser(id) as User; // e se retornar null?
const config = JSON.parse(raw) as AppConfig; // JSON.parse retorna any: qualquer shape passa
✅ Bom: uma checagem de verdade, ou um schema que valida o dado
const raw = await fetchUser(id);
if (!raw) throw new NotFoundError({ message: `User ${id} not found.` });
const user = raw; // narrowado para User
const parsed = AppConfigSchema.parse(JSON.parse(raw)); // Zod valida e retorna AppConfig
DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.