TypeScript na prática: 10 boas práticas para o compilador pegar os bugs antes de você

TypeScript na prática: 10 boas práticas para o compilador pegar os bugs antes de você

O TypeScript só protege o código até onde a configuração e o estilo de escrita deixam. Um projeto com any espalhado, as para calar o compilador e enums copiados do Java compila sem erro e quebra em produção do mesmo jeito que um projeto em JavaScript puro.

Este artigo reúne dez práticas que uso no dia a dia, cada uma com código que você pode colar no editor e ver o erro aparecer. Todos os exemplos foram testados no TypeScript 7.0.2, a versão atual, lançada em julho de 2026 com o compilador reescrito em Go.

Preparando o ambiente

Crie uma pasta, instale o TypeScript e os tipos do Node:

mkdir ts-good-practices && cd ts-good-practices
npm init -y
npm pkg set type=module
npm install -D typescript @types/node
npx tsc -v

O npm pkg set type=module diz ao Node que os arquivos do projeto são ES Modules. Sem isso, a combinação de "module": "nodenext" com verbatimModuleSyntax (que aparece no tsconfig abaixo) gera erro em todo export.

Duas mudanças das versões 6.0 e 7.0 pegam muita gente de surpresa. A primeira: o campo types agora vem vazio por padrão, então o compilador não carrega mais tudo que está em node_modules/@types. Se aparecer Cannot find name 'process', falta "types": ["node"]. A segunda: rootDir passou a ser a pasta do tsconfig, e quem tem o código em src precisa declarar isso.

Este é o tsconfig.json usado em todos os exemplos:

{
  "compilerOptions": {
    "target": "es2025",
    "module": "nodenext",
    "rootDir": "./src",
    "outDir": "./dist",
    "types": ["node"],

    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,

    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true
  },
  "include": ["src"]
}

Para checar os tipos sem gerar JavaScript, rode npx tsc --noEmit. Vale colocar esse comando no pipeline de CI.

1. Ligue as flags que o strict não liga

Desde o TypeScript 6.0, strict vem ligado por padrão. Mesmo assim, deixe a opção escrita no tsconfig: fica claro para quem lê e o projeto não muda de comportamento se alguém rodar uma versão antiga.

O modo strict não cobre tudo. Duas flags fora dele evitam uma classe inteira de bugs.

noUncheckedIndexedAccess faz todo acesso por índice retornar T | undefined. Sem ela, o TypeScript assume que prices[5] existe:

const prices: number[] = [10, 20, 30];
const first = prices[5];
console.log(first.toFixed(2));
// error TS18048: 'first' is possibly 'undefined'.

const stock: Record<string, number> = { coffee: 10 };
const tea = stock["tea"];
console.log(tea + 1);
// error TS18048: 'tea' is possibly 'undefined'.

Sem a flag, os dois trechos compilam e o primeiro estoura em tempo de execução com Cannot read properties of undefined.

exactOptionalPropertyTypes separa "propriedade ausente" de "propriedade com valor undefined". A diferença importa quando você faz Object.keys, spread de objetos ou envia o objeto para uma API que trata os dois casos de forma diferente:

type User = { name: string; nickname?: string };

const user: User = { name: "Ana", nickname: undefined };
// error TS2375: Type '{ name: string; nickname: undefined; }' is not
// assignable to type 'User' with 'exactOptionalPropertyTypes: true'.

Se o undefined explícito for intencional, declare nickname?: string | undefined. Ligar essas flags num projeto grande gera muitos erros de uma vez. Uma saída é ativar em um pacote ou pasta por vez.

2. Use unknown no lugar de any

any desliga a checagem de tipos. Qualquer acesso passa, e o erro só aparece em produção:

function parseBad(raw: string) {
  const data: any = JSON.parse(raw);
  return data.user.name.toUpperCase(); // compila, quebra com "{}"
}

unknown também aceita qualquer valor, mas obriga você a provar o formato antes de usar:

function parseGood(raw: string) {
  const data: unknown = JSON.parse(raw);
  return data.user.name;
  // error TS18046: 'data' is of type 'unknown'.
}

A prova é feita com um type guard, uma função que retorna value is Tipo. Dentro do if, o TypeScript passa a tratar o valor com o tipo confirmado:

type UserPayload = { user: { name: string } };

function isUserPayload(value: unknown): value is UserPayload {
  return (
    typeof value === "object" &&
    value !== null &&
    "user" in value &&
    typeof value.user === "object" &&
    value.user !== null &&
    "name" in value.user &&
    typeof value.user.name === "string"
  );
}

function parseSafe(raw: string): string {
  const data: unknown = JSON.parse(raw);
  if (!isUserPayload(data)) {
    throw new Error("Invalid payload");
  }
  return data.user.name.toUpperCase(); // tipado
}

O mesmo vale para o catch. Com strict, a variável de erro é unknown, porque em JavaScript qualquer coisa pode ser lançada, inclusive uma string:

try {
  parseSafe("{}");
} catch (error) {
  console.log(error.message);
  // error TS18046: 'error' is of type 'unknown'.

  if (error instanceof Error) {
    console.log(error.message); // ok
  }
}

Escrever guards à mão para payloads grandes cansa rápido. Para isso existe o Zod, que gera o tipo e a validação a partir de um único schema. Mostrei o passo a passo no artigo Validação com Zod no TypeScript.

3. Modele estados com uniões discriminadas

Um padrão comum para estado de requisição é um objeto com vários campos opcionais:

type RequestStateBad = {
  loading: boolean;
  data?: string[];
  error?: string;
};

Nada impede loading: true com error preenchido ao mesmo tempo. A união discriminada resolve isso: cada estado é um tipo separado, identificado por um campo literal (aqui, status):

type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

function render(state: RequestState<string[]>): string {
  switch (state.status) {
    case "idle":
      return "Nada carregado ainda";
    case "loading":
      return "Carregando...";
    case "success":
      return state.data.join(", "); // data só existe aqui
    case "error":
      return `Falhou: ${state.error}`;
    default:
      return assertNever(state);
  }
}

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

O assertNever no default é a checagem de exaustividade. Se alguém adicionar { status: "cancelled" } à união e esquecer de tratar no switch, o build quebra:

error TS2345: Argument of type '{ status: "cancelled"; }'
is not assignable to parameter of type 'never'.

O compilador aponta cada switch que precisa ser atualizado. Em um código com dezenas de telas, isso substitui uma busca manual por texto.

4. Prefira satisfies a anotações e ao as

Anotar o tipo de uma constante parece a opção mais segura, mas às vezes joga fora informação. Veja um mapa de rotas:

type Route = { path: string; auth: boolean };

const routesAnnotated: Record<string, Route> = {
  home: { path: "/", auth: false },
  dashboard: { path: "/dashboard", auth: true },
};

routesAnnotated.dashbord; // erro de digitação, e compila sem aviso

Como o tipo declarado é Record<string, Route>, qualquer chave é aceita. O operador satisfies valida o objeto contra o tipo sem substituir o tipo inferido:

const routes = {
  home: { path: "/", auth: false },
  dashboard: { path: "/dashboard", auth: true },
} satisfies Record<string, Route>;

routes.dashbord;
// error TS2551: Property 'dashbord' does not exist on type '...'.
// Did you mean 'dashboard'?

type RouteName = keyof typeof routes; // "home" | "dashboard"

E a validação continua funcionando. Se faltar um campo, o erro aparece na linha certa:

const routesBroken = {
  home: { path: "/" },
} satisfies Record<string, Route>;
// error TS2741: Property 'auth' is missing in type '{ path: string; }'

Já o as é uma afirmação sua para o compilador, que ele aceita sem conferir. Este trecho compila e quebra ao rodar:

const route = {} as Route;
console.log(route.path.toUpperCase());
// TypeError: Cannot read properties of undefined (reading 'toUpperCase')

Reserve o as para casos em que você sabe algo que o compilador não tem como saber, e deixe um comentário explicando o motivo.

5. Troque enum por objeto as const

O enum é uma das poucas construções do TypeScript que gera código JavaScript. Isso tem consequência prática: o Node.js, a partir das versões 22.18 e 23.6, executa arquivos .ts diretamente, apenas removendo os tipos. Enums, namespaces com código e parameter properties não podem ser removidos assim e dão erro.

A flag erasableSyntaxOnly avisa sobre isso ainda no editor:

enum OldStatus {
  Active = "active",
  Blocked = "blocked",
}
// error TS1294: This syntax is not allowed when
// 'erasableSyntaxOnly' is enabled.

A alternativa é um objeto as const com um tipo de mesmo nome, derivado dos valores:

export const UserStatus = {
  Active: "active",
  Blocked: "blocked",
  Pending: "pending",
} as const;

export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];
// "active" | "blocked" | "pending"

function canLogin(status: UserStatus): boolean {
  return status === UserStatus.Active;
}

canLogin("active");           // ok, aceita o literal
canLogin(UserStatus.Blocked); // ok, aceita a constante
canLogin("deleted");
// error TS2345: Argument of type '"deleted"' is not assignable
// to parameter of type 'UserStatus'.

O uso fica igual ao do enum (UserStatus.Active), o valor em tempo de execução é um objeto comum e dá para passar a string direto, o que ajuda quando o dado vem de uma API ou do banco.

6. Diferencie IDs com branded types

No TypeScript, dois tipos com a mesma estrutura são compatíveis. Se UserId e OrderId forem string, nada impede passar um no lugar do outro. Esse bug costuma aparecer em funções com vários IDs como parâmetro.

Um branded type adiciona uma marca que só existe no sistema de tipos:

type Brand<T, Name extends string> = T & { readonly __brand: Name };

type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

function toUserId(value: string): UserId {
  if (!/^usr_[a-z0-9]+$/.test(value)) {
    throw new Error(`Invalid user id: ${value}`);
  }
  return value as UserId;
}

function findOrder(orderId: OrderId): void {
  console.log(orderId);
}

const userId = toUserId("usr_123");

findOrder(userId);
// error TS2345: Argument of type 'UserId' is not assignable
// to parameter of type 'OrderId'.

findOrder("ord_999");
// error TS2345: Argument of type 'string' is not assignable
// to parameter of type 'OrderId'.

Repare que o as aparece em um único lugar: a função que valida e cria o ID. O resto do código só recebe valores já conferidos. A mesma técnica serve para Email, Cents ou Cpf.

7. Derive tipos em vez de duplicar

Quando o mesmo formato aparece em vários tipos, eles acabam divergindo com o tempo. Os utility types do TypeScript permitem declarar o modelo uma vez e derivar o resto:

type Product = {
  id: string;
  name: string;
  price: number;
  stock: number;
  createdAt: Date;
};

type CreateProductInput = Omit<Product, "id" | "createdAt">;
type UpdateProductInput = Partial<CreateProductInput>;
type ProductSummary = Pick<Product, "id" | "name">;

function updateProduct(id: string, input: UpdateProductInput): void {
  // ...
}

updateProduct("p1", { price: 19.9 }); // ok
updateProduct("p1", { color: "blue" });
// error TS2353: Object literal may only specify known properties,
// and 'color' does not exist in type 'Partial<CreateProductInput>'.

Se amanhã Product ganhar o campo sku, os tipos de entrada acompanham sem nenhuma edição.

Para imutabilidade, use readonly em parâmetros de array e as const em configurações. Isso evita que uma função altere dados que não são dela:

function total(items: readonly Product[]): number {
  items.push({} as Product);
  // error TS2339: Property 'push' does not exist on type 'readonly Product[]'.
  return items.reduce((sum, item) => sum + item.price, 0);
}

const config = { apiUrl: "https://api.example.com", retries: 3 } as const;
config.retries = 5;
// error TS2540: Cannot assign to 'retries' because it is a read-only property.

O as const também transforma um array em fonte de tipo. Uma lista de valores válidos vira uma união, sem repetir nada:

const statuses = ["draft", "published", "archived"] as const;
type PostStatus = (typeof statuses)[number];
// "draft" | "published" | "archived"

8. Escreva generics com restrições

Um generic sem restrição costuma virar um any com outro nome. Compare:

function getPropertyBad(obj: any, key: string) {
  return obj[key]; // retorna any
}

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const product = { name: "Café especial", price: 42.5 };

const price = getProperty(product, "price"); // number
getProperty(product, "weight");
// error TS2345: Argument of type '"weight"' is not assignable
// to parameter of type '"name" | "price"'.

O K extends keyof T limita a chave às propriedades que o objeto tem, e o retorno T[K] preserva o tipo exato. O editor ainda sugere as chaves no autocomplete.

Um exemplo mais próximo do dia a dia, um groupBy tipado:

function groupBy<T, K extends PropertyKey>(
  items: readonly T[],
  getKey: (item: T) => K,
): Partial<Record<K, T[]>> {
  const result: Partial<Record<K, T[]>> = {};
  for (const item of items) {
    const key = getKey(item);
    (result[key] ??= []).push(item);
  }
  return result;
}

const orders = [
  { id: 1, status: "paid" as const },
  { id: 2, status: "pending" as const },
];

const byStatus = groupBy(orders, (order) => order.status);
byStatus.paid?.length; // chaves conhecidas: "paid" | "pending"

O retorno é Partial porque nem toda chave possível vai aparecer nos dados. Isso obriga quem usa a tratar o grupo vazio, em vez de assumir que ele existe.

9. Trate erros esperados como valor

Exceções não aparecem na assinatura da função. Quem chama parseAge(input) não sabe, pelo tipo, que ela pode lançar erro. Para falhas esperadas (entrada inválida, registro não encontrado), um tipo Result deixa o erro visível:

type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

type ParseError = { field: string; message: string };

function parseAge(input: string): Result<number, ParseError> {
  const age = Number(input);
  if (!Number.isInteger(age) || age < 0) {
    return { ok: false, error: { field: "age", message: "Idade inválida" } };
  }
  return { ok: true, value: age };
}

const result = parseAge("abc");

console.log(result.value);
// error TS2339: Property 'value' does not exist on type
// 'Result<number, ParseError>'.

if (result.ok) {
  console.log(result.value + 1);
} else {
  console.log(result.error.message);
}

É a mesma união discriminada da prática 3, agora aplicada a erros. O compilador só libera value depois que você checa ok. Quem vem de Go vai reconhecer a ideia do if err != nil.

Isso não substitui exceções. Falhas inesperadas, como banco fora do ar, continuam sendo throw. O Result é para os casos que fazem parte da regra de negócio.

10. Valide as fronteiras com assertion functions

Variáveis de ambiente, parâmetros de rota e respostas de API chegam sem garantia. process.env.API_URL, por exemplo, é string | undefined:

const apiUrl = process.env.API_URL;
apiUrl.startsWith("https");
// error TS18048: 'apiUrl' is possibly 'undefined'.

Uma assertion function valida o valor e, se não lançar erro, estreita o tipo para o resto do escopo:

function assertIsDefined<T>(
  value: T,
  name: string,
): asserts value is NonNullable<T> {
  if (value === undefined || value === null) {
    throw new Error(`${name} is not defined`);
  }
}

const apiUrl = process.env.API_URL;
assertIsDefined(apiUrl, "API_URL");
apiUrl.startsWith("https"); // ok: string

A diferença para o process.env.API_URL! (o operador de non-null assertion) é que a função confere de verdade. Se a variável faltar, a aplicação falha na inicialização com uma mensagem clara, e não minutos depois em uma requisição qualquer.

A regra geral: valide nas bordas (entrada HTTP, arquivo, fila, variável de ambiente) e deixe o miolo da aplicação trabalhar só com tipos confiáveis.

Checklist para o code review

  • "strict": true explícito, mais noUncheckedIndexedAccess e exactOptionalPropertyTypes.
  • unknown em dados externos e no catch; any só com justificativa escrita.
  • Estados e erros modelados como uniões discriminadas, com assertNever no default.
  • satisfies em objetos de configuração; as apenas em funções de conversão validadas.
  • Objetos as const no lugar de enum, com erasableSyntaxOnly ligado.
  • IDs de entidades diferentes com branded types.
  • Tipos derivados com Omit, Pick e Partial, em vez de copiados.
  • Generics com extends sempre que o parâmetro tiver formato esperado.
  • Validação nas bordas com type guards, assertion functions ou Zod.

Referências

  • MICROSOFT. TSConfig Reference. TypeScript Documentation, 2026. Disponível em: https://www.typescriptlang.org/tsconfig/. Acesso em: 24 set. 2026.
  • MICROSOFT. Narrowing. TypeScript Handbook, 2026. Disponível em: https://www.typescriptlang.org/docs/handbook/2/narrowing.html. Acesso em: 24 set. 2026.
  • MICROSOFT. TypeScript 4.9: the satisfies operator. TypeScript Documentation, 2022. Disponível em: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html. Acesso em: 24 set. 2026.
  • NODE.JS. Modules: TypeScript. Node.js Documentation, 2026. Disponível em: https://nodejs.org/api/typescript.html. Acesso em: 24 set. 2026.
  • ROSENWASSER, Daniel. Announcing TypeScript 6.0. TypeScript Dev Blog, 23 mar. 2026. Disponível em: https://devblogs.microsoft.com/typescript/announcing-typescript-6-0/. Acesso em: 24 set. 2026.
  • ROSENWASSER, Daniel. Announcing TypeScript 7.0. TypeScript Dev Blog, 8 jul. 2026. Disponível em: https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/. Acesso em: 24 set. 2026.

Comentários (0)

Nenhum comentário ainda. Seja o primeiro.

Continue lendo

Como Instalar e Acessar o PostgreSQL no WSL com DBeaver

Como Instalar e Acessar o PostgreSQL no WSL com DBeaver

PostgreSQL é um dos sistemas de gerenciamento de banco de dados mais populares, conhecido por sua robustez e recursos avançados. Se você está utilizando o Windows Subsystem for Linux (WSL) para desenvolvimento, pode ser…

Banco de dados, Postgres, Ubuntu, Windows, WSL
Tutorial: Exportando Imagens Base64 Usando Laravel Excel

Tutorial: Exportando Imagens Base64 Usando Laravel Excel

Neste tutorial, vamos aprender como exportar uma lista de cadastros de clientes para uma planilha Excel, incluindo as fotos dos clientes que estão armazenadas como strings base64. Usaremos o pacote Laravel Excel para…

Intermediários, Laravel, php, Tutorial