When I first started using TypeScript, I thought it was just "JavaScript with types."
I'd slap : string and : number on everything and call it a day.
But TypeScript's real power isn't in basic type annotations. It's in the advanced patterns that make impossible states impossible.
1. Discriminated Unions — Model State Transitions
One of the most common bugs in JavaScript is accessing properties that don't exist yet.
// ❌ Bad: status and data can get out of sync
type RequestState = {
status: 'idle' | 'loading' | 'success' | 'error'
data?: User
error?: string
}
function UserProfile({ state }: { state: RequestState }) {
// BUG: data might be undefined even when status is 'success'
if (state.status === 'success') {
return <div>{state.data.name}</div>
}
}
The problem: status and data are independent. Nothing stops you from having status: 'success' with data: undefined.
Solution: Use discriminated unions to make invalid states unrepresentable:
// ✅ Good: status and data are coupled
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: string }
function UserProfile({ state }: { state: RequestState }) {
if (state.status === 'success') {
// TypeScript knows data exists here
return <div>{state.data.name}</div>
}
}
Now it's impossible to have success without data or error without an error message.
2. Type Guards — Narrow Types Safely
Type guards let you refine types at runtime:
type Shape = Circle | Rectangle | Triangle
function isCircle(shape: Shape): shape is Circle {
return 'radius' in shape
}
function getArea(shape: Shape): number {
if (isCircle(shape)) {
// TypeScript knows shape is Circle here
return Math.PI * shape.radius ** 2
}
// Handle other shapes...
}
The shape is Circle syntax tells TypeScript to narrow the type inside the if block.
3. Utility Types — Don't Rewrite Types
TypeScript has built-in utilities for common type transformations:
type User = {
id: string
name: string
email: string
role: 'admin' | 'user'
}
// Pick specific fields
type UserPreview = Pick<User, 'name' | 'email'>
// Omit specific fields
type PublicUser = Omit<User, 'email'>
// Make all fields optional
type PartialUser = Partial<User>
// Make all fields required
type RequiredUser = Required<User>
// Make all fields readonly
type ImmutableUser = Readonly<User>
// Extract function return type
function getUser() {
return { id: '1', name: 'Alice' }
}
type UserData = ReturnType<typeof getUser>
These save you from duplicating type definitions.
4. Const Assertions — Make Literals Specific
Without as const, TypeScript widens literal types to their base type:
// ❌ Type is string[]
const roles = ['admin', 'user', 'guest']
// ✅ Type is readonly ['admin', 'user', 'guest']
const roles = ['admin', 'user', 'guest'] as const
type Role = typeof roles[number] // 'admin' | 'user' | 'guest'
This is especially useful for configuration objects:
const config = {
api: 'https://api.example.com',
timeout: 5000,
retries: 3,
} as const
// Now config properties are readonly and have literal types
5. Template Literal Types — Generate Types
You can generate types from string patterns:
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type Endpoint = '/users' | '/posts' | '/comments'
// Generate all possible combinations
type APIRoute = `${HTTPMethod} ${Endpoint}`
// Results in: 'GET /users' | 'POST /users' | ... (16 combinations)
type EventName = `on${Capitalize<'click' | 'hover' | 'focus'>}`
// Results in: 'onClick' | 'onHover' | 'onFocus'
This is powerful for type-safe routing and event handling.
6. Generic Constraints — Enforce Structure
Generics let you write reusable code while maintaining type safety:
// ❌ Too loose: T could be anything
function getProperty<T>(obj: T, key: string) {
return obj[key] // Error: string can't index T
}
// ✅ Constrain T to objects with string keys
function getProperty<T extends Record<string, any>>(
obj: T,
key: keyof T
) {
return obj[key] // ✅ Type-safe
}
const user = { name: 'Alice', age: 30 }
getProperty(user, 'name') // ✅ Works
getProperty(user, 'email') // ❌ Error: 'email' doesn't exist
7. Branded Types — Prevent Mixing Similar Types
Sometimes you have two types that are structurally identical but semantically different:
type UserId = string
type PostId = string
function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }
const userId: UserId = '123'
const postId: PostId = '456'
getUser(postId) // ❌ Should error but doesn't!
Solution: Brand your types:
type UserId = string & { readonly __brand: 'UserId' }
type PostId = string & { readonly __brand: 'PostId' }
function userId(id: string): UserId {
return id as UserId
}
function postId(id: string): PostId {
return id as PostId
}
const user = userId('123')
const post = postId('456')
getUser(post) // ✅ Now this errors!
8. Avoid any — Use unknown Instead
any disables type checking. unknown forces you to check types:
// ❌ any allows anything
function processData(data: any) {
return data.value.toUpperCase() // No error, but will crash if data.value isn't a string
}
// ✅ unknown requires type checking
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'value' in data) {
const value = (data as { value: unknown }).value
if (typeof value === 'string') {
return value.toUpperCase() // ✅ Safe
}
}
throw new Error('Invalid data')
}
Common Mistakes
1. Using interface when type is better
Interfaces are for object shapes that might be extended. For unions, intersections, and utilities, use type:
// ✅ Use interface for extendable objects
interface User {
name: string
}
// ✅ Use type for unions and complex types
type Status = 'idle' | 'loading' | 'success'
type Result = Success | Error
2. Not enabling strict mode
Always set "strict": true in tsconfig.json. It enables:
strictNullChecks— prevents null/undefined bugsstrictFunctionTypes— catches parameter mismatchesnoImplicitAny— forces explicit types
3. Overusing types
Don't add types where TypeScript can infer them:
// ❌ Redundant type annotation
const count: number = 5
// ✅ Let TypeScript infer
const count = 5
Takeaway
TypeScript isn't just about adding types to JavaScript. It's about modeling your domain so precisely that entire classes of bugs become impossible.
Learn discriminated unions, type guards, and utility types. They'll save you more time than any linter.