TypeScript tips I learned after 3 years in production
After three years writing TypeScript in production — big refactors, 200k+ line codebases, teams of 5+ devs — I learned that the typical blog tips (strict mode, no-any, etc.) are the foundation, but the real jump comes from patterns that change how you think about code.
It’s not about types. It’s about explicit contracts.
1. Discriminated unions: the hidden superpower
Most code I see uses enums or loose strings to model state. That’s an anti-pattern.
❌ What I always see
type UserStatus = 'active' | 'inactive' | 'pending';
function getUserStatus(user: User) {
if (user.status === 'active') {
// do something
return { canLogin: true, features: user.features };
}
// 'inactive' or 'pending' — what do I return here?
}
✅ What works
type User =
| { status: 'active'; features: string[]; lastSeen: Date }
| { status: 'inactive'; deactivatedAt: Date; reason: string }
| { status: 'pending'; inviteToken: string; expiresAt: Date };
function getLoginInfo(user: User) {
switch (user.status) {
case 'active':
// TypeScript knows `user.features` exists
return { canLogin: true, features: user.features };
case 'inactive':
// TypeScript knows `user.reason` exists
return { canLogin: false, reason: user.reason };
case 'pending':
// TypeScript knows `user.inviteToken` exists
return { canLogin: false, token: user.inviteToken };
}
}
Why it matters: TypeScript forces you to handle every case explicitly. If you add a new status and forget a case, the compiler warns you. No more bugs in production because “we forgot to validate that case.”
In production: this pattern alone saved us about 8 bugs in the last year. Worth its weight in gold.
2. Utility types: the toolkit you already have
TypeScript ships with utility types that solve 80% of the transformations you do by hand.
Partial, Required, Pick, Omit
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
preferences: { theme: 'light' | 'dark' };
}
// "Partial" for updates
type UserUpdate = Partial<Pick<User, 'name' | 'email' | 'preferences'>>;
// "Pick" for response DTOs
type PublicUser = Pick<User, 'id' | 'name' | 'avatar'>;
// "Omit" for creation
type NewUser = Omit<User, 'id' | 'createdAt'>;
Record, Readonly, NonNullable
// Map roles to permissions
type Permissions = Record<'admin' | 'user' | 'guest', string[]>;
// "Readonly" for type-level immutability
type FrozenConfig = Readonly<{
apiUrl: string;
retries: number;
}>;
// "NonNullable" after a filter
const validUsers: NonNullable<User>[] = users.filter(Boolean) as NonNullable<User>[];
Tip: before writing a new type, think if
Pick,Omit,Partial, orRequiredalready solve it.
3. Exhaustiveness checks with never
This is the pattern I use the most. It forces the compiler to verify you handled all cases.
type Shape = 'circle' | 'square' | 'triangle';
function area(shape: Shape): number {
switch (shape) {
case 'circle': return Math.PI;
case 'square': return 1;
case 'triangle': return 0.5;
default:
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}
If you add a new shape ('hexagon') and forget a case, TypeScript flags an error: Type 'string' is not assignable to type 'never'. The compiler forces you to handle it.
In CI: this pattern caught 12+ unhandled cases in the last year, before they reached production.
4. Type guards: let the code flow, not fight
Type guards tell TypeScript “if this check passes, know that the type changed.”
interface ApiResponse<T> {
data: T;
error?: { code: number; message: string };
}
function handleResponse<T>(res: ApiResponse<T>): T {
if (res.error) {
// Here `res.error` exists, it's narrowed
throw new Error(`${res.error.code}: ${res.error.message}`);
}
// Here TypeScript knows `res.error` is undefined
return res.data;
}
Custom type guards
interface Dog { bark(): void; }
interface Cat { meow(): void; }
function isDog(pet: Dog | Cat): pet is Dog {
return (pet as Dog).bark !== undefined;
}
function interact(pet: Dog | Cat) {
if (isDog(pet)) {
pet.bark(); // OK
} else {
pet.meow(); // OK
}
}
5. Template literal types: dynamic types without losing safety
type Event = 'click' | 'focus' | 'blur';
type Element = 'button' | 'input' | 'select';
type Handler = `on${Capitalize<Event>}`; // 'onClick' | 'onFocus' | 'onBlur'
type ElementEvent = `${Element}:${Event}`; // 'button:click' | 'input:focus' | etc.
const handler: Handler = 'onClick'; // OK
const invalid: Handler = 'onHover'; // ERROR
Useful for analytics events, Redux action types, query param keys, etc.
6. as const vs explicit types
// ❌ You lose literal types
const config = {
apiUrl: 'https://api.example.com',
retries: 3,
};
// ✅ TypeScript infers the exact literal types
const config = {
apiUrl: 'https://api.example.com',
retries: 3,
} as const;
// Now `config.retries` is type `3`, not `number`
as const is especially useful for arrays:
const ROUTES = ['/', '/blog', '/contact'] as const;
type Route = typeof ROUTES[number]; // '/' | '/blog' | '/contact'
7. The satisfies trick (TS 4.9+)
as forces a type (sometimes lying). satisfies verifies the value matches the type without changing inference.
type Config = Record<string, string>;
const config = {
apiUrl: 'https://api.example.com',
retries: 3, // ⚠️ should be string
} satisfies Config;
// ^^^^^^ Error: Type 'number' is not assignable to type 'string'
Better than as Config because it preserves the exact literal types for autocomplete.
Bonus: the pattern I use most in APIs
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: { code: number; message: string } };
async function fetchUser(id: string): Promise<ApiResult<User>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
return { ok: false, error: { code: res.status, message: res.statusText } };
}
const data = await res.json();
return { ok: true, data };
} catch (e) {
return { ok: false, error: { code: 0, message: String(e) } };
}
}
// And the consumer:
const result = await fetchUser('123');
if (result.ok) {
// result.data is narrowed to User
console.log(result.data.name);
} else {
// result.error is narrowed
console.error(result.error.message);
}
Zero exceptions at runtime. Every branch is type-checked.
Closing
TypeScript isn’t about adding types to JavaScript. It’s about making the compiler your first reviewer. The more restrictive you are with types, the more bugs you catch before they reach production.
These patterns changed how I think about code. They’re not theory — they’re things I apply every day in real projects.
If you want me to dive deeper into any of them (discriminated unions, template literal types, type-level programming), let me know in the comments.
René Kuhm · Senior Fullstack Engineer · TecnoDespegue