6 mintypescriptprodutividadesetup

TypeScript em projetos solo: o setup mínimo para escapar da fadiga

O setup de TypeScript que uso em todo projeto solo: strict, sem mais, e algumas convenções que evitam o tipo de fricção que faz programador desistir do tipo.


Eu já vi gente desistir de TypeScript em projeto solo. Geralmente o discurso é o mesmo: "não compensa o trabalho de tipar tudo, atrapalha mais do que ajuda".

Quase sempre, a real é que essa pessoa se complicou no setup. Ligou flags demais, tentou tipar tudo manualmente, importou uma biblioteca de validação no dia 1, deixou os erros do compilador acumularem.

TypeScript em projeto solo deve ser invisível na maior parte do tempo. Você liga, escreve código, e o editor te avisa quando você cagou algo. Esse post é sobre o setup que faz isso acontecer.

A configuração do tsconfig.json

Esse é o tsconfig.json que coloco em todo projeto Next.js solo:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "resolveJsonModule": true,
    "incremental": true,
    "allowJs": true,
    "paths": {
      "@/*": ["./src/*"]
    },
    "plugins": [{ "name": "next" }]
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

A flag que importa: strict: true. Tudo o resto é boilerplate de Next.js.

strict: true ativa simultaneamente: noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict. Ligo todas de uma vez e raramente desligo alguma.

Por quê? Porque desligar uma flag strict tende a adiar a dor em vez de resolver — ela costuma voltar mais tarde, em forma de bug. strictNullChecks te força a tratar null e undefined — exatamente os bugs mais comuns em JavaScript. Ligar parcialmente acaba trocando segurança de longo prazo por conforto imediato.

O que não faço

Algumas flags strict avançadas que deixo desligadas:

  • noUncheckedIndexedAccess: faz acesso por índice retornar T | undefined. É correto, mas chato. Em projeto solo prefiro deixar desligado e tomar cuidado.
  • exactOptionalPropertyTypes: distingue { x?: T } de { x: T | undefined }. Tecnicamente útil, praticamente irritante.
  • noImplicitReturns: ligo às vezes, mas geralmente o linter já pega.

A regra: as flags strict básicas pegam 95% dos bugs com 10% da fricção. As avançadas pegam 5% dos bugs com 90% da fricção. Para projeto solo, o trade-off não compensa.

Convenção 1: type para forma, interface para extensão

Eu uso 99% type, 1% interface. A regra:

  • type para tudo. É o default.
  • interface quando alguém vai estender (autor de biblioteca, declaração de plugin).
// Default — type
type User = {
  id: string;
  name: string;
  email: string;
};

type UserPreview = Pick<User, "id" | "name">;

// Exceção — interface, porque outro arquivo vai estender
interface NextConfigPlugins {
  [key: string]: unknown;
}

Essa convenção elimina debates do tipo "é melhor type ou interface?" — em projeto solo, é só "type, exceto se precisar estender".

Convenção 2: Discriminated unions para todos os states

Sempre que tenho estado com múltiplas formas, uso union discriminada:

// Bom
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

// Ruim
type FetchState<T> = {
  loading: boolean;
  data?: T;
  error?: Error;
};

A diferença é dramática. Com a primeira, o compilador te força a tratar todos os casos no switch. Com a segunda, é fácil ler data enquanto loading ainda é true (estado intermediário inválido).

Convenção 3: as const para arrays e objetos imutáveis

Quando defino tabelas de constantes, uso as const:

const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"

const TIER_LIMITS = {
  FREE: { tournaments: 1, players: 16 },
  PRO: { tournaments: -1, players: 64 },
  MAX: { tournaments: -1, players: 128 },
} as const;

type Tier = keyof typeof TIER_LIMITS; // "FREE" | "PRO" | "MAX"

Isso te dá tipos derivados de constantes runtime. Você não precisa duplicar a lista.

Convenção 4: Zod para borders, type-only para internals

Para validação de dados que vêm de fora (request body, response de API, formulário), uso Zod:

import { z } from "zod";

const CreateTournamentSchema = z.object({
  name: z.string().min(3).max(100),
  format: z.enum(["king-of-the-beach", "super-eight", "singles-bracket"]),
  maxPlayers: z.number().int().positive(),
});

type CreateTournamentInput = z.infer<typeof CreateTournamentSchema>;

O z.infer é importante: você define uma vez (o schema), e o tipo TypeScript é derivado dele. Sem duplicação.

Para tipos internos (modelos de domínio, estruturas de cálculo), uso só type — sem validação runtime, porque eles vêm de código meu, não de input externo.

Convenção 5: nunca any. Quase nunca unknown.

A regra que mais economiza dor:

  • any = abrir mão de toda proteção. Use só para escapar de tipagem horrorosa de biblioteca antiga.
  • unknown = "não sei o tipo, vou descobrir antes de usar". Use para inputs externos não-validados.
  • T específico = o default. Sempre.

Quando tem any no meu código, geralmente é um TODO escondido. Quando tem unknown, é geralmente seguido de um Zod parse logo abaixo.

Convenção 6: Helpers de tipo no src/types.ts

Em todo projeto, tem um arquivo src/types.ts que centraliza helpers genéricos:

// src/types.ts

export type Nullable<T> = T | null;
export type Optional<T> = T | undefined;

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

export type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

Não exagero. Adiciono helper só quando uso em três lugares. Antes disso, é YAGNI.

O loop de fricção que evita desistência

A maioria das pessoas que desiste de TypeScript faz isso porque o ciclo "escrevo código → vejo erro → corrijo tipos → vejo outro erro → corrijo mais tipos" se torna insuportável.

Três coisas reduzem esse ciclo:

1. VS Code com TS server na sua máquina (não no Cloudflare). TypeScript pode ser pesado. Se você está em ambiente lento, o IntelliSense atrasa, e você só descobre o erro quando salva. Roda local.

2. Type-only imports onde possível.

import type { Tournament } from "./types";

Imports só-de-tipo são removidos no build. Eles existem só pra TypeScript. Use sempre que o import for de tipo apenas.

3. ESLint com @typescript-eslint. Pega coisas que o compilador deixa passar — variáveis não usadas, comparações sempre verdadeiras, promises não esperadas. Em projeto solo, configuro recommended e seguir adiante. Não invento regra própria.

A meta: TypeScript invisível

O setup ideal é aquele que você não nota. Você escreve código JavaScript normal, o editor te avisa quando algo está errado, e quando o projeto compila, você sabe que pelo menos os bugs óbvios não estão lá.

Para chegar nisso:

  • Liga strict, deixa as flags avançadas desligadas
  • type para tudo, interface quase nunca
  • Discriminated unions para state
  • Zod nas bordas, type no interior
  • any é último recurso

Esse setup escala de projeto pessoal de 5k linhas até SaaS de 50k+. Funciona pra mim em todos os tamanhos. E mais importante: não me faz desistir do tipo no meio do caminho.