6 Hábitos de TypeScript para Código AI-Friendly
Pedi um refactor simples pro assistente de IA. O diff veio limpo, só que a rota era /user/ e o app usa /users/. O catch só fazia console.log e seguia o fluxo.
O modelo não inventou do nada. Ele preencheu os buracos que os tipos deixaram abertos.
Hoje escrevo TypeScript pensando em duas leitoras: o compilador e quem vai sugerir patch no meu PR. Quanto mais o tipo descreve o que pode existir, menos chute entra no diff.
Seis hábitos que uso no dia a dia.
1. Rotas não são string solta
router.push('/users/' + id) funciona até o dia em que alguém (humano ou IA) escreve /user/.
// ❌ Qualquer string passa. Todo mundo chuta.
function navigateTo(path: string) { ... }
Defina o formato das rotas com template literal types ou um mapa as const:
const ROUTES = {
HOME: '/',
USERS: '/users',
USER_DETAIL: '/users/:id',
} as const;
type AppRoute = typeof ROUTES[keyof typeof ROUTES];
function navigate(route: AppRoute) { /* ... */ }
navigate(ROUTES.USER_DETAIL);
As rotas válidas ficam num lugar só. Quem for editar o arquivo vê a lista inteira antes de sugerir caminho novo.
2. Três booleanos no mesmo componente viram estado impossível
isLoading, isError e isSuccess ao mesmo tempo? O TypeScript não impede. A IA também não, e aí você renderiza dado com spinner por cima.
type DataState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function UserList({ state }: { state: DataState<User[]> }) {
switch (state.status) {
case 'loading': return <Spinner />;
case 'error': return <ErrorMsg error={state.error} />;
case 'success': return <List data={state.data} />;
}
}
O switch no status obriga tratar cada caso. data só existe quando faz sentido existir.
3. string não distingue userId de email
Para o compilador, os dois são texto. sendEmail(to: string, from: string) troca a ordem e ninguém reclama.
type Brand<K, T> = K & { __brand: T };
type Email = Brand<string, 'Email'>;
type UserId = Brand<string, 'UserId'>;
function createEmail(value: string): Email {
if (!value.includes('@')) throw new Error('Email inválido');
return value as Email;
}
function sendInvite(to: Email, from: UserId) { /* ... */ }
const adminId = 'u-123' as UserId;
const userEmail = createEmail('john@example.com');
sendInvite(userEmail, adminId); // ✅
// sendInvite(adminId, userEmail); // ❌
4. Comentário não substitui validação
// Preço deve ser positivo não impede price: -10 no refactor automático.
type Price = Brand<number, 'Price'>;
function createPrice(value: number): Price {
if (value < 0) throw new Error('Preço deve ser positivo');
return value as Price;
}
interface Product {
name: string;
price: Price;
}
Quem precisa de Price passa por createPrice. A regra roda na criação, não na revisão de PR.
5. throw some no meio do caminho
Num try/catch, a IA costuma logar e seguir. O tipo do erro some.
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
type FetchError =
| { type: 'NetworkError' }
| { type: 'NotFound'; id: string };
async function getUser(id: string): Promise<Result<User, FetchError>> {
// retorna objetos; não faz throw
}
const result = await getUser('123');
if (!result.ok) {
switch (result.error.type) {
case 'NotFound': /* ... */
case 'NetworkError': /* ... */
}
}
Os erros possíveis estão na assinatura. O switch fecha o circuito.
6. Dado de API não entra com as User
Branded types ajudam dentro do app. Resposta de rede é outra história: as User mente pro compilador e a IA acredita.
import { z } from 'zod';
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(id: string) {
const data = await fetch(`/api/users/${id}`).then((res) => res.json());
return UserSchema.parse(data);
}
Se parse passou, o tipo corresponde ao que chegou.
Da próxima vez que o diff vier estranho, olho os tipos antes de culpar o modelo. Muitas vezes o contrato estava frouxo demais.