TypeScript catches bugs before runtime by adding static types to JavaScript. But effective TypeScript isn't about typing everything—it's about typing strategically. These tips help you write cleaner, safer code without drowning in type annotations.
Let TypeScript infer
TypeScript infers types automatically. You don't need to annotate everything:
// Unnecessary annotation
const count: number = 0;
const name: string = 'Alice';
const items: string[] = ['a', 'b', 'c'];
// Let TypeScript infer
const count = 0; // number
const name = 'Alice'; // string
const items = ['a', 'b', 'c']; // string[]
Add explicit types for:
- Function parameters
- Public API boundaries
- When inference isn't specific enough
Interface vs type
| Use case | Prefer |
|---|---|
| Object shapes | interface |
| Unions | type |
| Intersections | type |
| Extending | interface |
| Mapped types | type |
// Interface for objects
interface User {
id: string;
name: string;
email: string;
}
// Type for unions and complex types
type Status = 'idle' | 'loading' | 'success' | 'error';
type Result<T> = { data: T } | { error: string };
Both work for most cases. Pick a convention and stick with it.
Union types
Model "one of several" with unions:
type Status = 'pending' | 'approved' | 'rejected';
function handleStatus(status: Status) {
switch (status) {
case 'pending':
return 'Waiting...';
case 'approved':
return 'Accepted!';
case 'rejected':
return 'Denied';
}
}
TypeScript enforces exhaustive checks—add a new status and the compiler warns.
Discriminated unions
Add a common property to narrow types:
type ApiResponse =
| { status: 'success'; data: User[] }
| { status: 'error'; message: string }
| { status: 'loading' };
function render(response: ApiResponse) {
switch (response.status) {
case 'success':
// TypeScript knows response.data exists
return response.data.map(user => user.name);
case 'error':
// TypeScript knows response.message exists
return `Error: ${response.message}`;
case 'loading':
return 'Loading...';
}
}
The status field discriminates between variants.
Type narrowing
TypeScript narrows types based on control flow:
function process(value: string | number | null) {
if (value === null) {
return 'No value';
}
// value: string | number
if (typeof value === 'string') {
return value.toUpperCase(); // value: string
}
return value.toFixed(2); // value: number
}
Custom type guards
For complex narrowing:
interface Dog { bark(): void }
interface Cat { meow(): void }
function isDog(pet: Dog | Cat): pet is Dog {
return 'bark' in pet;
}
function speak(pet: Dog | Cat) {
if (isDog(pet)) {
pet.bark(); // TypeScript knows it's Dog
} else {
pet.meow(); // TypeScript knows it's Cat
}
}
Avoid any
any disables type checking. Use alternatives:
| Instead of | Use |
|---|---|
any for unknown data |
unknown |
any for flexible functions |
Generics |
any for third-party |
Proper types or @ts-expect-error |
// Bad: any
function parse(data: any) {
return data.items; // No type checking
}
// Better: unknown with narrowing
function parse(data: unknown) {
if (typeof data === 'object' && data !== null && 'items' in data) {
return data.items; // Type narrowed
}
throw new Error('Invalid data');
}
// Best: generic with constraints
function parse<T extends { items: unknown[] }>(data: T) {
return data.items; // Typed correctly
}
Utility types
TypeScript provides built-in type utilities:
| Utility | Purpose |
|---|---|
Partial<T> |
All properties optional |
Required<T> |
All properties required |
Pick<T, K> |
Select specific properties |
Omit<T, K> |
Exclude specific properties |
Record<K, V> |
Object type with key/value |
Readonly<T> |
All properties readonly |
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
// For updates, make everything optional
type UpdateUser = Partial<User>;
// For public display, omit sensitive fields
type PublicUser = Omit<User, 'email'>;
// For lookup maps
type UserMap = Record<string, User>;
Const assertions
Preserve literal types:
// Without as const
const config = {
api: 'https://api.example.com',
timeout: 5000,
};
// config.api: string
// With as const
const config = {
api: 'https://api.example.com',
timeout: 5000,
} as const;
// config.api: 'https://api.example.com'
Useful for configuration and constant values.
Nullish handling
Use optional chaining and nullish coalescing:
interface User {
profile?: {
avatar?: string;
};
}
// Old way
const avatar = user && user.profile && user.profile.avatar
? user.profile.avatar
: 'default.png';
// Modern way
const avatar = user?.profile?.avatar ?? 'default.png';
Generic functions
Write flexible, typed functions:
// Without generics: loses type info
function first(arr: unknown[]): unknown {
return arr[0];
}
// With generics: preserves type
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = first([1, 2, 3]); // number | undefined
const str = first(['a', 'b']); // string | undefined
Generic constraints
Constrain what generics accept:
interface HasId {
id: string;
}
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
Strict mode
Enable strict in tsconfig.json:
{
"compilerOptions": {
"strict": true
}
}
This enables:
strictNullChecks- null/undefined safetynoImplicitAny- no inferred anystrictFunctionTypes- function type safetystrictPropertyInitialization- class property init
Type assertions (sparingly)
Use as when you know more than TypeScript:
// After manual validation
const data = JSON.parse(response) as Config;
// After DOM query
const input = document.getElementById('email') as HTMLInputElement;
Prefer type guards when possible—they're runtime checked.
Branded types
Create distinct types from primitives:
type UserId = string & { readonly brand: unique symbol };
type OrderId = string & { readonly brand: unique symbol };
function getUser(id: UserId) { ... }
function getOrder(id: OrderId) { ... }
const userId = '123' as UserId;
const orderId = '456' as OrderId;
getUser(userId); // OK
getUser(orderId); // Error! OrderId is not UserId
Prevents mixing up IDs of the same underlying type.
Summary
Let TypeScript infer when obvious. Use interfaces for objects, types for unions. Model states with discriminated unions. Narrow types with control flow and type guards. Avoid any—use unknown and generics instead. Enable strict mode for maximum safety. These patterns catch bugs early while keeping code readable.
