TypeScript tem um sistema de tipos rico, mas você não precisa entender tudo para ser produtivo. A realidade é que 90% do trabalho é feito por um punhado de recursos que você usa todos os dias. Esta é a referência prática — primitivos, unions, generics, tipos utilitários e os padrões de narrowing que fazem tudo se encaixar. Sem teoria abstrata; cada exemplo é o tipo de código que você escreve em um projeto real. Se você conhece JavaScript bem e fez algum TypeScript mas quer solidificar seu modelo mental, isto é para você. A história completa está no TypeScript Handbook — este artigo é a versão 80/20.

Primitivos e Literais

Os tipos primitivos do TypeScript mapeiam diretamente para os tipos de runtime do JavaScript: string, number, boolean, null, undefined, bigint e symbol. Você usará os três primeiros constantemente; os demais surgem em contextos específicos.

ts
// Primitives — the foundation
let userId: number = 42;
let username: string = 'alice';
let isActive: boolean = true;
let deletedAt: Date | null = null;     // nullable pattern
let refreshToken: string | undefined;  // optional pattern

Onde TypeScript fica interessante é nos tipos literais. Em vez de apenas dizer que um valor é uma string, você pode dizer que deve ser um de um conjunto específico de strings. Isso é muito mais útil do que uma anotação string simples porque TypeScript pode capturar valores inválidos em tempo de compilação:

ts
type Direction = 'north' | 'south' | 'east' | 'west';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type StatusCode = 200 | 201 | 400 | 401 | 403 | 404 | 500;

function navigate(dir: Direction) {
  console.log(`Moving ${dir}`);
}

navigate('north');  // ✅
navigate('up');     // ❌ Argument of type '"up"' is not assignable to type 'Direction'

// Combine with string for "known values plus free-form"
type EventName = 'click' | 'focus' | 'blur' | (string & {});
O truque (string & {}) no final mantém o autocompletar funcionando para os valores conhecidos enquanto ainda aceita qualquer string. É um padrão comum em bibliotecas de sistemas de design.

interface vs type — Quando Usar Qual

Esta é a pergunta que todo iniciante em TypeScript faz. A resposta prática: use interface para formas de objetos — especialmente aquelas que representam contratos de API públicos ou que outros tipos irão estender. Use type para unions, interseções, tipos mapeados e qualquer coisa que não seja puramente uma forma de objeto. Na prática, eles são amplamente intercambiáveis para formas de objetos — escolha uma convenção e seja consistente dentro de uma base de código.

ts
// interface — object shapes, extensible via extends
interface User {
  id: number;
  email: string;
  createdAt: Date;
}

interface AdminUser extends User {
  role: 'admin';
  permissions: string[];
}

// type — unions, intersections, aliases for anything
type ID = string | number;
type Nullable<T> = T | null;

// Intersection — combine two shapes (common for mixins / HOC props)
type WithTimestamps = {
  createdAt: Date;
  updatedAt: Date;
};

type UserRecord = User & WithTimestamps;

// type is required here — interface can't express a union of shapes
type ApiResponse<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; code: number };

Uma diferença significativa: interfaces suportam mesclagem de declarações — você pode declarar a mesma interface duas vezes e TypeScript mescla as definições. É assim que bibliotecas aumentam tipos globais (por exemplo, adicionando propriedades ao Window). Tipos não mesclam; redeclarar um tipo é um erro.

Tipos Union e Interseção

Tipos union (A | B) dizem "este valor é A ou B". Tipos de interseção (A & B) dizem "este valor é A e B ao mesmo tempo". Unions estão em todo lugar no código real — o padrão mais poderoso que eles habilitam é a union discriminada, que é como você modela dados que podem estar em estados diferentes sem recorrer a campos opcionais em todo lugar.

ts
// Discriminated union — model API response states cleanly
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string; retryable: boolean };

// TypeScript narrows the type inside each branch
function render<T>(state: FetchState<T>) {
  switch (state.status) {
    case 'idle':    return '<p>Not started</p>';
    case 'loading': return '<p>Loading...</p>';
    case 'success': return `<pre>${JSON.stringify(state.data)}</pre>`;
    case 'error':   return `<p>Error: ${state.error}</p>`;
  }
}

// Intersection — common in React/Angular for composing prop types
type ButtonBaseProps = {
  label: string;
  disabled?: boolean;
};

type IconButtonProps = ButtonBaseProps & {
  icon: string;
  iconPosition: 'left' | 'right';
};

O padrão de union discriminada depende de um discriminante — uma propriedade literal (aqui status) que é única para cada variante. TypeScript usa essa propriedade para estreitar o tipo completo dentro de ramos condicionais, dando-lhe acesso seguro de tipos aos campos específicos da variante como data ou error.

Generics — A Parte Que Confunde Todo Mundo

O problema motivador: você quer uma função que funcione em múltiplos tipos, mas usar any joga fora todas as informações de tipo. Generics resolvem isso — são parâmetros de tipo, escritos em colchetes angulares, que permitem que uma função ou interface trabalhe sobre uma família de tipos mantendo plena segurança de tipos.

ts
// Without generics — any loses all information
function first(arr: any[]): any {
  return arr[0];
}
const x = first([1, 2, 3]); // x is 'any' — TypeScript can't help you here

// With generics — T is inferred from the argument
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const n = first([1, 2, 3]);       // n: number | undefined ✅
const s = first(['a', 'b', 'c']); // s: string | undefined ✅

A sintaxe <T> declara um parâmetro de tipo. Você pode nomeá-lo como quiser — T é apenas a convenção para um único tipo genérico. Quando você chama a função, TypeScript infere T dos argumentos, então raramente você precisa escrevê-lo explicitamente. Interfaces genéricas são igualmente úteis para modelar formas de API:

ts
// Generic interface — the API wrapper pattern every codebase has
interface ApiResponse<T> {
  data: T;
  meta: {
    page: number;
    totalPages: number;
    totalItems: number;
  };
}

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

// The response type is fully typed — no casting needed
async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const res = await fetch('/api/users');
  return res.json();
}

const response = await fetchUsers();
response.data[0].email; // ✅ TypeScript knows this is a string
response.meta.totalPages; // ✅ TypeScript knows this is a number

// Constrained generics — T must have an 'id' field
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

const user = findById(users, 42); // T inferred as User
Quando usar generics: quando você se encontra escrevendo a mesma assinatura de função duas vezes com tipos diferentes, ou fazendo cast com as para obter o tipo de retorno que você quer. Esses são os dois sinais de que um generic seria mais limpo.

Tipos Utilitários Que Você Realmente Usará

TypeScript vem com um conjunto de tipos utilitários embutidos que transformam tipos existentes em novos. Eles eliminam a necessidade de duplicar ou ajustar manualmente as definições de tipo. Aqui estão os que aparecem constantemente em bases de código reais:

ts
interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
  createdAt: Date;
}

// Partial<T> — all properties become optional
// Perfect for PATCH/update payloads
type UpdateUserPayload = Partial<User>;
// { id?: number; name?: string; email?: string; ... }

async function updateUser(id: number, payload: Partial<User>) {
  return fetch(`/api/users/${id}`, {
    method: 'PATCH',
    body: JSON.stringify(payload)
  });
}

// Required<T> — all properties become required (opposite of Partial)
interface Config {
  apiUrl?: string;
  timeout?: number;
  retries?: number;
}
type ResolvedConfig = Required<Config>;
// { apiUrl: string; timeout: number; retries: number }

// Pick<T, K> — keep only specified properties
type UserSummary = Pick<User, 'id' | 'name' | 'email'>;
// Use in list views where you only need the display fields

// Omit<T, K> — exclude specified properties
type PublicUser = Omit<User, 'role' | 'createdAt'>;
// Safe to expose in API responses

// Readonly<T> — prevents mutation (great for config and frozen state)
const config: Readonly<ResolvedConfig> = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3
};
// config.timeout = 10000; // ❌ Cannot assign to 'timeout' because it is a read-only property

// Record<K, V> — typed dictionary
type UserCache = Record<number, User>;
const cache: UserCache = {};
cache[42] = { id: 42, name: 'Alice', email: '[email protected]', role: 'user', createdAt: new Date() };

// Common pattern: mapping string keys to a known value shape
type FeatureFlags = Record<string, { enabled: boolean; rolloutPct: number }>;

// ReturnType<T> — infer the return type of a function
function createSession(userId: number) {
  return {
    token: crypto.randomUUID(),
    userId,
    expiresAt: new Date(Date.now() + 86_400_000)
  };
}

type Session = ReturnType<typeof createSession>;
// { token: string; userId: number; expiresAt: Date }
// — no need to define the type separately and keep it in sync

unknown vs any vs never

Esses três tipos confundem a maioria dos desenvolvedores até entenderem qual problema cada um resolve. any é a saída de emergência — desativa a verificação de tipos completamente para aquele valor. É útil quando se migra JavaScript para TypeScript, mas o uso excessivo derrota o propósito do TypeScript. unknown é a alternativa segura de tipos: o valor pode ser qualquer coisa, mas você deve estreitá-lo antes de poder fazer qualquer coisa com ele. never é um tipo que nunca pode ocorrer — o fundo da hierarquia de tipos.

ts
// any — type checking disabled, use sparingly
function dangerousTransform(input: any) {
  return input.toUpperCase(); // TypeScript won't warn even if this crashes at runtime
}

// unknown — safe alternative, forces you to check before using
function safeTransform(input: unknown): string {
  if (typeof input === 'string') {
    return input.toUpperCase(); // ✅ narrowed to string inside this block
  }
  if (typeof input === 'number') {
    return input.toFixed(2);    // ✅ narrowed to number
  }
  throw new Error(`Cannot transform value of type ${typeof input}`);
}

// Common use: parsing untrusted data (API responses, localStorage, user input)
function parseConfig(raw: unknown): ResolvedConfig {
  if (
    typeof raw === 'object' &&
    raw !== null &&
    'apiUrl' in raw &&
    typeof (raw as any).apiUrl === 'string'
  ) {
    return raw as ResolvedConfig;
  }
  throw new Error('Invalid config format');
}

// never — the exhaustive switch pattern
type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * shape.radius ** 2;
    case 'square': return shape.side ** 2;
    default:
      // If you add a new Shape variant and forget to handle it here,
      // TypeScript will error: Type 'NewShape' is not assignable to type 'never'
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

Narrowing de Tipos na Prática

O narrowing de tipos é como TypeScript refina um tipo amplo (string | number, unknown, uma union discriminada) para um tipo específico dentro de um bloco condicional. A documentação de narrowing do TypeScript cobre cada guard em profundidade — aqui estão os padrões que você escreverá diariamente.

ts
// typeof — primitive narrowing
function formatValue(val: string | number | boolean): string {
  if (typeof val === 'string')  return val.trim();
  if (typeof val === 'number')  return val.toLocaleString();
  return val ? 'Yes' : 'No';
  // TypeScript knows val must be boolean here
}

// instanceof — class/object narrowing
function handleError(err: unknown): string {
  if (err instanceof Error)   return err.message;
  if (typeof err === 'string') return err;
  return 'An unknown error occurred';
}

// in operator — property existence check
type Cat = { meow(): void };
type Dog = { bark(): void };

function makeNoise(animal: Cat | Dog) {
  if ('meow' in animal) {
    animal.meow(); // TypeScript narrows to Cat
  } else {
    animal.bark(); // TypeScript narrows to Dog
  }
}

// Discriminated union narrowing (most common in component state)
type LoadState<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string };

function renderState<T>(state: LoadState<T>) {
  if (state.status === 'success') {
    console.log(state.data);    // ✅ data is accessible
  } else if (state.status === 'error') {
    console.error(state.message); // ✅ message is accessible
  }
}

// Type predicates — custom type guards
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'email' in obj
  );
}

// After isUser() returns true, TypeScript knows obj is User
function processPayload(payload: unknown) {
  if (isUser(payload)) {
    console.log(payload.email); // ✅ fully typed
  }
}

O operador typeof é a ferramenta de narrowing mais fundamental. Combinado com instanceof, o operador in e verificações de propriedades discriminantes, você pode lidar com praticamente qualquer cenário de narrowing sem recorrer a any ou casts inseguros.

Conclusão

O sistema de tipos do TypeScript recompensa aprender bem os fundamentos em vez de memorizar recursos obscuros. Tipos literais e unions discriminadas eliminam categorias inteiras de bugs de runtime. Generics permitem escrever abstrações reutilizáveis e seguras de tipos. Tipos utilitários como Partial, Pick, Omit e ReturnType mantêm seus tipos DRY. E unknown com narrowing dá a você a segurança dos tipos mesmo nas bordas onde os dados vêm do mundo exterior. A melhor maneira de solidificar tudo isso é experimentar interativamente — o TypeScript Playground permite colar qualquer snippet e ver os tipos inferidos instantaneamente, sem necessidade de configuração. Quando você está trabalhando com dados JSON em projetos TypeScript, as ferramentas Formatador JSON e JSON para TypeScript neste site podem gerar definições de interface a partir de payloads de API reais automaticamente.