TypeScript não é mais opcional para aplicações em produção. É o padrão. Todo framework relevante — Next.js, Remix, SvelteKit, Nuxt — é TypeScript-first. A documentação oficial do TypeScript reflete essa realidade. Todo produto SaaS sério, da API do Stripe à plataforma da Vercel, roda TypeScript em produção. A questão não é se usar TypeScript, mas como usá-lo bem.
Depois de construir sistemas em produção em diversos setores — incluindo o AeroCopilot (100% TypeScript, 18 pacotes, 173 tabelas de banco de dados) e plataformas enterprise na Avenue Code para clientes como Banco Itaú e Walmart — estes são os padrões que separam projetos hobby de código de produção.
Comece com Strict Mode. Sem Exceções.
A mudança de configuração com maior impacto que você pode fazer é habilitar strict mode no seu tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true
}
}
strict: true habilita strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitAny, noImplicitThis e alwaysStrict. Cada uma captura uma categoria de bugs em tempo de compilação em vez de em produção.
noUncheckedIndexedAccess é a flag mais subutilizada. Sem ela, acessar array[0] retorna T em vez de T | undefined — mesmo que o array possa estar vazio. Esta única flag previne uma classe inteira de erros em runtime.
Times que começam com TypeScript frouxo e planejam "apertar depois" nunca o fazem. Comece strict. Ajuste regras individuais apenas quando tiver um motivo concreto e documentado.
Unions Discriminadas ao Invés de Type Assertions
Type assertions (as SomeType) são escotilhas de escape que burlam o type checker. Cada assertion é uma declaração de que você sabe mais que o compilador — e essa confiança frequentemente é equivocada.
Em vez disso, modele seu domínio com unions discriminadas:
// Ruim: type assertions e campos opcionais por todo lado
interface ApiResponse {
status: string;
data?: UserData;
error?: string;
}
// Bom: union discriminada torna estados inválidos irrepresentáveis
type ApiResponse =
| { status: 'success'; data: UserData }
| { status: 'error'; error: string }
| { status: 'loading' };
Com unions discriminadas, o compilador garante tratamento exaustivo. Se você adiciona um novo status, TypeScript sinaliza todo switch statement que não o trata. Esse padrão escala lindamente para máquinas de estado complexas — fluxos de autenticação, estados de processamento de pagamento, wizards multi-etapa.
O AeroCopilot usa unions discriminadas extensivamente para estados de planos de voo. Um plano de voo é draft, submitted, approved ou rejected — e cada estado carrega dados associados diferentes. O sistema de tipos impede que código acesse dados de aprovação em um plano de voo em rascunho. Bugs que seriam crashes em runtime se tornam erros de compilação.
Branded Types para Segurança de Domínio
O sistema de tipos estrutural do TypeScript significa que qualquer string é intercambiável com qualquer outra string. Um userId é o mesmo tipo que um orderId é o mesmo tipo que um randomGarbage. Branded types resolvem isso:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function createUserId(id: string): UserId {
// Validar o formato
if (!id.match(/^usr_[a-z0-9]{16}$/)) {
throw new Error(`Formato inválido de user ID: ${id}`);
}
return id as UserId;
}
function getOrder(orderId: OrderId): Order { /* ... */ }
const userId = createUserId('usr_abc123def456ghi7');
getOrder(userId); // Erro TypeScript: UserId não é atribuível a OrderId
Branded types são especialmente valiosos em sistemas com muitos identificadores — o que é todo produto SaaS. No AeroCopilot, fazemos brand de números de registro de aeronaves, IDs de licença de piloto e códigos de aeródromo. O sistema de tipos impede que se passe um ID de piloto onde um registro de aeronave é esperado, capturando incompatibilidades que testes unitários podem perder.
Zod para Validação em Runtime
Tipos TypeScript desaparecem em runtime. Eles te protegem durante o desenvolvimento mas oferecem zero garantias sobre dados entrando no seu sistema via APIs, bancos de dados, input de usuário ou serviços de terceiros. Zod preenche essa lacuna:
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['admin', 'member', 'viewer']),
metadata: z.record(z.string()).optional(),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Validação em runtime nas fronteiras do sistema
function createUser(raw: unknown): CreateUserInput {
return CreateUserSchema.parse(raw);
}
O padrão: defina schemas Zod em toda fronteira do sistema (endpoints de API, submissões de formulário, respostas de APIs externas, queries de banco com filtros dinâmicos). Infira tipos TypeScript dos schemas. Isso dá uma única fonte de verdade que garante tipos tanto em tempo de compilação quanto em runtime.
Para aplicações Next.js, combine Zod com server actions para type safety de ponta a ponta, do formulário ao banco de dados. Usamos esse padrão em todo projeto — o schema é definido uma vez e validado em toda camada.
Tratamento de Erros Que Não Mente
O padrão try/catch em TypeScript tem uma falha fundamental: catch te dá unknown, e a maioria dos desenvolvedores imediatamente faz cast para Error sem verificar. Pior, muitas funções que podem falhar não sinalizam isso no tipo de retorno.
Adote o padrão Result para operações que podem falhar previsivelmente:
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
async function fetchUser(id: UserId): Promise<Result<User, 'NOT_FOUND' | 'FORBIDDEN'>> {
const user = await db.user.findUnique({ where: { id } });
if (!user) {
return { success: false, error: 'NOT_FOUND' };
}
if (!canAccess(user)) {
return { success: false, error: 'FORBIDDEN' };
}
return { success: true, data: user };
}
// O chamador é forçado a tratar ambos os casos
const result = await fetchUser(userId);
if (!result.success) {
// TypeScript sabe que result.error é 'NOT_FOUND' | 'FORBIDDEN'
switch (result.error) {
case 'NOT_FOUND': return notFound();
case 'FORBIDDEN': return forbidden();
}
}
// TypeScript sabe que result.data é User
console.log(result.data.name);
Reserve try/catch para erros verdadeiramente inesperados — falhas de rede, falta de memória, bugs. Use tipos Result para modos de falha esperados. A assinatura da função se torna honesta sobre o que pode dar errado, e o chamador é forçado a tratar.
Estrutura de Módulos para Monorepos
O monorepo de 18 pacotes do AeroCopilot funciona porque cada pacote tem limites claros e exports explícitos. Os padrões que fazem isso funcionar:
Barrel exports com intenção. Cada pacote expõe uma API pública através do index.ts. Detalhes internos de implementação não são exportados. Isso impede que consumidores dependam de estruturas internas que podem mudar.
// packages/auth/src/index.ts — a API pública
export { authenticate } from './authenticate';
export { authorize } from './authorize';
export type { Session, Permission } from './types';
// Helpers internos NÃO são exportados
// import { hashPassword } from '@repo/auth/internal' — isso não deveria funcionar
Arquitetura em camadas dentro dos pacotes. Cada pacote segue a mesma estrutura interna: types → utils → lógica core → adapters. Dependências fluem para dentro: adapters dependem da lógica core, lógica core depende de types. Nunca o inverso.
Declarações de dependência explícitas. Todo pacote declara suas dependências no package.json, mesmo para pacotes internos do monorepo. Sem imports implícitos entre limites de pacote. Isso garante que pacotes possam ser extraídos, testados e deployados independentemente.
TypeScript com Prisma: Acesso Type-Safe ao Banco de Dados
Prisma gera tipos TypeScript diretamente do seu schema de banco de dados. Isso é transformador para aplicações em produção — seu schema de banco se torna a única fonte de verdade para tipos de dados em toda a aplicação.
// Prisma gera tipos do seu schema
// Sem definições manuais de tipo para modelos do banco
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Type safety completo: TypeScript sabe exatamente quais campos existem
const user = await prisma.user.findUnique({
where: { id: userId },
include: { orders: true },
});
// user.orders é tipado como Order[] por causa do include
// user.nonExistentField seria um erro de compilação
A prática-chave: nunca burle o sistema de tipos do Prisma com raw queries a menos que a performance exija absolutamente. Quando usar raw queries, encapsule-as em funções tipadas que validam o output contra schemas Zod.
Para queries complexas, use os tipos gerados pelo Prisma para construir helpers de query type-safe:
import type { Prisma } from '@prisma/client';
function buildUserFilter(params: {
role?: string;
active?: boolean;
search?: string;
}): Prisma.UserWhereInput {
return {
...(params.role && { role: params.role }),
...(params.active !== undefined && { active: params.active }),
...(params.search && {
OR: [
{ name: { contains: params.search, mode: 'insensitive' } },
{ email: { contains: params.search, mode: 'insensitive' } },
],
}),
};
}
Configuração e Variáveis de Ambiente
Variáveis de ambiente são a fonte mais comum de falhas TypeScript em runtime. A correção é simples: valide-as na inicialização.
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
OPENAI_API_KEY: z.string().startsWith('sk-'),
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().default(3000),
});
export const env = envSchema.parse(process.env);
Se uma variável obrigatória estiver faltando, a aplicação crasha na inicialização com uma mensagem de erro clara — não cinco minutos depois em um handler de conexão de banco com um erro críptico de undefined.
Padrões de Performance
TypeScript em si não tem custo em runtime — compila para JavaScript. Mas padrões TypeScript podem encorajar overhead de runtime se você não for cuidadoso:
Evite hierarquias de classe. Cadeias profundas de herança criam overhead desnecessário de objetos e tornam o código mais difícil de tree-shake. Prefira composição com objetos simples e funções.
Use assertions const para dados estáticos. as const transforma arrays e objetos em tipos literais readonly, habilitando melhor inferência e prevenindo mutação acidental:
const ROLES = ['admin', 'member', 'viewer'] as const; type Role = (typeof ROLES)[number]; // 'admin' | 'member' | 'viewer'
Aproveite template literal types para rotas de API. Para aplicações com muitos endpoints de API, template literal types capturam typos de rota em tempo de compilação:
type ApiRoute = `/api/${string}`;
function fetchApi(route: ApiRoute): Promise<Response> {
return fetch(route);
}
fetchApi('/api/users'); // OK
fetchApi('/users'); // Erro TypeScript
Testando Código TypeScript
Type safety reduz mas não elimina a necessidade de testes. Foque o esforço de teste em:
Lógica de negócio. Os cálculos, transformações e árvores de decisão que definem o comportamento do seu produto. Tipos garantem que as formas de dados certas fluam por essas funções; testes garantem que a lógica está correta.
Fronteiras de integração. Queries de banco, chamadas de API e interações com serviços de terceiros. Mocke a dependência externa, teste a lógica de integração.
Edge cases que o sistema de tipos não consegue expressar. Arrays vazios que não deveriam estar vazios. Números que devem ser positivos. Strings que devem corresponder a um formato. Zod captura alguns desses; testes capturam o resto.
Pule testar o que o sistema de tipos já garante. Se uma função aceita User e retorna Order, você não precisa de um teste verificando que não retorna uma string. O compilador já garante isso.
O Checklist de TypeScript para Produção
Antes de deployar qualquer aplicação TypeScript para produção, verifique:
- [ ]
strict: truecomnoUncheckedIndexedAccess - [ ] Validação Zod em toda fronteira do sistema
- [ ] Variáveis de ambiente validadas na inicialização
- [ ] Nenhum tipo
anyfora de exceções explicitamente documentadas - [ ] Nenhum type assertion (
as) fora de construtores de branded types - [ ] Unions discriminadas para todas as máquinas de estado
- [ ] Tipos Result para modos de falha esperados
- [ ] Tipos Prisma como única fonte de verdade para modelos de dados
- [ ] Todos os pacotes com APIs públicas explícitas e mínimas
- [ ] ESLint configurado com regras TypeScript-aware
- [ ] Pipeline de CI executa
tsc --noEmitem todo commit
Essas práticas não são teóricas. São os padrões que aplicamos a todo sistema em produção que construímos, da plataforma de aviação do AeroCopilot a sistemas enterprise em escala. O poder do TypeScript está no seu sistema de tipos — mas apenas se você usá-lo completamente, honestamente e sem escotilhas de escape.
A lacuna entre um codebase TypeScript que captura bugs em tempo de compilação e um que apenas fornece autocomplete é a lacuna entre uma aplicação de produção e um projeto hobby. Escolha construir para produção desde o primeiro dia.
