Contact

TypeScript Tips I Wish I Knew Earlier

November 25, 2025
Nick Paolini
6 min read
TypeScriptJavaScriptWeb DevelopmentBest Practices
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') // ❌ Error

This 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') // ❌ Error

Great 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: true

5. 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 exist

This 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!