6 TypeScript Habits for AI-Friendly Code
- architecture
- typescript
- ai-engineering
- code-quality
I asked the AI assistant for a simple refactor. The diff looked clean, except the route was /user/ and the app uses /users/. The catch only did console.log and kept going.
The model didn't invent that from nothing. It filled the gaps the types left open.
These days I write TypeScript for two readers: the compiler and whoever will suggest a patch on my PR. The more the type describes what can exist, the less guesswork ends up in the diff.
Six habits I use day to day.
1. Routes aren't loose strings
router.push('/users/' + id) works until someone (human or AI) writes /user/.
// ❌ Qualquer string passa. Todo mundo chuta.
function navigateTo(path: string) { ... }
Define route shapes with template literal types or an as const map:
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);
Valid routes live in one place. Whoever edits the file sees the full list before suggesting a new path.
2. Three booleans on the same component become impossible state
isLoading, isError, and isSuccess at the same time? TypeScript won't stop you. Neither will the AI, and then you render data with a spinner on top.
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} />;
}
}
The switch on status forces you to handle each case. data only exists when it should.
3. string doesn't tell userId from email
To the compiler, both are text. sendEmail(to: string, from: string) swaps the order and nobody complains.
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. A comment doesn't replace validation
// Preço deve ser positivo doesn't stop price: -10 in an automated refactor.
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;
}
Anything that needs a Price goes through createPrice. The rule runs at creation time, not in PR review.
5. throw disappears along the way
In a try/catch, the AI usually logs and moves on. The error type vanishes.
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': /* ... */
}
}
Possible errors sit in the signature. The switch closes the loop.
6. API data doesn't enter as as User
Branded types help inside the app. Network responses are another story: as User lies to the compiler and the AI believes it.
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);
}
If parse passed, the type matches what arrived.
Next time a diff looks off, I check the types before blaming the model. Often the contract was too loose.