TypeScript Tips I Wish I Knew Earlier
I've been using TypeScript for a few years now, and I keep discovering patterns that make me think "where has this been all my life?" Here are five tips that significantly improved my TypeScript code.
1. Use satisfies for Better Type Checking
Before TypeScript 4.9, we had to choose between letting TypeScript infer the type (flexible but less safe) or explicitly typing it (safe but verbose).
The satisfies operator gives us both:
// Without satisfies - type is too broad
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
}
// Type: { apiUrl: string; timeout: number; retries: number }
// We can accidentally assign wrong types later
// With satisfies - exact type checking
const config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3,
} satisfies Config
// TypeScript ensures it matches Config, but keeps literal types
config.apiUrl // Type: "https://api.example.com" (literal!)
config.timeout // Type: 5000 (literal!)This is especially useful for configurations and constants where you want both validation and precise types.
2. Discriminated Unions for State Management
Instead of optional properties that create impossible states, use discriminated unions:
// ❌ Bad: Impossible states are possible
interface UserState {
loading: boolean
error?: Error
data?: User
}
// What does this mean?
const state: UserState = {
loading: true,
error: new Error("Failed"),
data: { id: 1, name: "John" }
}
// ✅ Good: Only valid states are possible
type UserState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: User }
// TypeScript knows which properties exist
function handleState(state: UserState) {
if (state.status === 'success') {
console.log(state.data.name) // ✅ data exists here
}
if (state.status === 'error') {
console.log(state.error.message) // ✅ error exists here
}
}No more checking if both error and data are defined. The type system enforces valid combinations.
3. Template Literal Types for Dynamic Keys
Template literal types let you create types from string patterns:
// Create types for CSS properties
type CSSProperty = 'color' | 'backgroundColor' | 'fontSize'
type CSSPropertyValue = `${CSSProperty}Value`
// Type: "colorValue" | "backgroundColorValue" | "fontSizeValue"
// Practical example: API routes
type APIRoute = '/users' | '/posts' | '/comments'
type APIMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type APIEndpoint = `${APIMethod} ${APIRoute}`
// Type: "GET /users" | "POST /users" | "GET /posts" | ...
// Use it in your API client
async function request(endpoint: APIEndpoint, body?: unknown) {
const [method, route] = endpoint.split(' ')
// TypeScript knows these are valid!
}
request('GET /users') // ✅
request('PATCH /users') // ❌ ErrorThis is incredibly powerful for things like CSS-in-JS, API clients, and any domain with string patterns.
4. Use const Assertions for Immutable Data
When you want TypeScript to infer the most specific type possible, use as const:
// Without const assertion
const routes = ['/', '/about', '/blog']
// Type: string[]
routes.push('/contact') // ✅ Allowed
// With const assertion
const routes = ['/', '/about', '/blog'] as const
// Type: readonly ['/', '/about', '/blog']
routes.push('/contact') // ❌ Error: readonly
// Access with literal types
function navigate(route: typeof routes[number]) {
// route type: '/' | '/about' | '/blog'
}
navigate('/') // ✅
navigate('/contact') // ❌ ErrorGreat for configurations, enums, and any data that shouldn't change:
const CONFIG = {
api: {
baseUrl: 'https://api.example.com',
timeout: 5000,
},
features: {
darkMode: true,
analytics: false,
}
} as const
// Now you get autocomplete for all values
CONFIG.api.baseUrl // Type: "https://api.example.com"
CONFIG.features.darkMode // Type: true5. Generic Constraints for Flexible Functions
When writing generic functions, constrain the type parameter to get better intellisense:
// ❌ Too generic - no intellisense
function getValue<T>(obj: T, key: string) {
return obj[key] // Error: can't index T with string
}
// ✅ Constrained generic - intellisense works!
function getValue<T extends object, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key]
}
const user = { id: 1, name: 'John', email: 'john@example.com' }
getValue(user, 'name') // ✅ Type: string
getValue(user, 'age') // ❌ Error: 'age' doesn't existThis pattern is especially useful for utility functions:
// Pick specific properties from an object
function pick<T extends object, K extends keyof T>(
obj: T,
...keys: K[]
): Pick<T, K> {
const result = {} as Pick<T, K>
for (const key of keys) {
result[key] = obj[key]
}
return result
}
const user = { id: 1, name: 'John', email: 'john@example.com', age: 30 }
const userPreview = pick(user, 'id', 'name')
// Type: { id: number; name: string }Bonus: Type Predicates for Better Type Guards
Type predicates let you create custom type guards that TypeScript understands:
// Without type predicate
function isString(value: unknown) {
return typeof value === 'string'
}
const value: unknown = "hello"
if (isString(value)) {
value.toUpperCase() // ❌ Error: value is still unknown
}
// With type predicate
function isString(value: unknown): value is string {
return typeof value === 'string'
}
const value: unknown = "hello"
if (isString(value)) {
value.toUpperCase() // ✅ Works! value is string
}Extremely useful for filtering arrays:
function isDefined<T>(value: T | undefined | null): value is T {
return value !== undefined && value !== null
}
const values = [1, 2, undefined, 3, null, 4]
const defined = values.filter(isDefined)
// Type: number[] (not (number | undefined | null)[])Putting It All Together
Here's a real-world example combining several of these patterns:
// API response types
type ApiResponse<T> =
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: T }
// API endpoints
const ENDPOINTS = {
users: '/api/users',
posts: '/api/posts',
} as const
type Endpoint = typeof ENDPOINTS[keyof typeof ENDPOINTS]
// Generic fetch with constraints
async function fetchData<T>(
endpoint: Endpoint
): Promise<ApiResponse<T>> {
try {
const response = await fetch(endpoint)
if (!response.ok) {
return {
status: 'error',
error: new Error(`HTTP ${response.status}`)
}
}
const data = await response.json()
return { status: 'success', data }
} catch (error) {
return {
status: 'error',
error: error instanceof Error ? error : new Error('Unknown error')
}
}
}
// Usage with full type safety
const response = await fetchData<User[]>(ENDPOINTS.users)
if (response.status === 'success') {
console.log(response.data[0].name) // ✅ Full autocomplete
}Resources for Learning More
What TypeScript patterns have made your code better? I'd love to hear about your favorite tips!