close

DEV Community

Cover image for TypeScript Tips That Actually Matter in Real Projects (including the satisfies operator)
Gavin Cettolo
Gavin Cettolo

Posted on

TypeScript Tips That Actually Matter in Real Projects (including the satisfies operator)

Eight habits to stop fighting the type system

Most TypeScript tutorials teach you the language.

This article teaches you how to use it.

There's a difference. The language has hundreds of features. A real project uses maybe twenty of them regularly, and about eight of them make up the difference between TypeScript that fights you and TypeScript that helps you.

These are those eight.

Each one comes from a pattern I've seen repeatedly in real codebases: first as an antipattern, then as a realization, then as a habit. The goal isn't to show off advanced type gymnastics. It's to show you the specific things that make your code safer, more readable, and less painful to maintain.


TL;DR

  • Most TypeScript pain comes from fighting the type system instead of working with it, any, manual casting, and loose types are the usual culprits.
  • A small set of features, discriminated unions, utility types, satisfies, as const, generics, solve the majority of real-world typing problems.
  • The best TypeScript isn't the most complex. It's the most precise.

Table of Contents


Tip 1: Use Discriminated Unions Instead of Optional Fields

This is the tip that changes how you model data in TypeScript. Once you see it, you'll spot the antipattern everywhere.

The antipattern

// ❌ A type that tries to represent multiple states with optional fields
interface ApiResponse {
  data?: User
  error?: string
  isLoading: boolean
}
Enter fullscreen mode Exit fullscreen mode

The problem: this type allows impossible states. Nothing stops you from having both data and error set at the same time, or neither set, or isLoading: false with no data and no error.

The type says "any combination of these fields is valid." Your domain says only three combinations are valid: loading, success, or error. The type isn't telling the truth.

The fix: discriminated unions

// ✅ Each state is explicit and mutually exclusive
type ApiResponse<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string }
Enter fullscreen mode Exit fullscreen mode

Now TypeScript knows exactly which fields exist in each state. When you narrow on status, you get precise autocomplete and type safety:

function renderUser(response: ApiResponse<User>) {
  switch (response.status) {
    case 'loading':
      return 'Loading...'
    case 'success':
      return response.data.name  // TypeScript knows data exists here
    case 'error':
      return response.error      // TypeScript knows error exists here
  }
}
Enter fullscreen mode Exit fullscreen mode

If you add a new state to ApiResponse and forget to handle it in the switch, TypeScript tells you. That's the real payoff: exhaustiveness checking. The type system enforces that every case is handled.

This pattern is especially powerful for modelling form states, async operations, and anything with multiple mutually exclusive conditions.


Tip 2: Stop Writing Types Twice with Utility Types

TypeScript ships with a set of built-in utility types that most developers underuse. They exist specifically to prevent you from duplicating type definitions.

The antipattern

// ❌ Manually maintaining two overlapping types
interface User {
  id: string
  email: string
  firstName: string
  lastName: string
  role: 'admin' | 'user'
  createdAt: string
}

// Manually duplicated, will drift from User when User changes
interface UpdateUserPayload {
  email?: string
  firstName?: string
  lastName?: string
}

// Manually duplicated again
interface UserPreview {
  id: string
  firstName: string
  lastName: string
}
Enter fullscreen mode Exit fullscreen mode

When User changes, you have to remember to update every manual derivative. You won't always remember.

The fix: utility types

// ✅ Derived from the source of truth, always in sync
interface User {
  id: string
  email: string
  firstName: string
  lastName: string
  role: 'admin' | 'user'
  createdAt: string
}

// Partial makes all fields optional
type UpdateUserPayload = Partial<Pick<User, 'email' | 'firstName' | 'lastName'>>

// Pick selects specific fields
type UserPreview = Pick<User, 'id' | 'firstName' | 'lastName'>

// Omit removes specific fields
type UserWithoutMeta = Omit<User, 'createdAt' | 'role'>

// Required makes all fields required (opposite of Partial)
type StrictUser = Required<User>

// Readonly makes all fields immutable
type ImmutableUser = Readonly<User>
Enter fullscreen mode Exit fullscreen mode

The key insight: UpdateUserPayload is now derived from User. When you add a field to User, you decide whether to include it in the derived types, but the base type is the single source of truth.

A practical combination that appears constantly in REST APIs:

// Create payload: no id or metadata, those are server-generated
type CreateUserPayload = Omit<User, 'id' | 'createdAt'>

// Update payload: everything optional except what can't change
type UpdateUserPayload = Partial<Omit<User, 'id' | 'createdAt' | 'role'>>
Enter fullscreen mode Exit fullscreen mode

Tip 3: Use satisfies to Validate Without Losing Inference

satisfies was introduced in TypeScript 4.9 and is still underused. It solves a specific problem that as and explicit type annotations both fail to handle cleanly.

The problem

When you annotate a variable with a type, TypeScript widens the inferred type to match the annotation, and you lose specific information:

// ❌ Explicit annotation loses specific type information
const config: Record<string, string> = {
  host: 'localhost',
  port: '5432',
}

config.host  // type: string, you've lost 'localhost'
config.nonExistent  // No error! The index signature allows any key
Enter fullscreen mode Exit fullscreen mode

When you skip the annotation, you get precise inference but no validation against a shape:

// ❌ No annotation, no validation
const config = {
  host: 'localhost',
  port: '5432',
}

config.host         // type: 'localhost' ✅
config.nonExistent  // Error ✅, but you have no shape validation
Enter fullscreen mode Exit fullscreen mode

The fix: satisfies

// ✅ Validates against the shape AND preserves specific types
type AppConfig = {
  host: string
  port: string
  database: string
}

const config = {
  host: 'localhost',
  port: '5432',
  database: 'myapp',
  unknownKey: 'oops',  // ❌ Error: Object literal may only specify known properties
} satisfies AppConfig

config.host  // type: 'localhost', specific type preserved ✅
Enter fullscreen mode Exit fullscreen mode

satisfies tells TypeScript: "validate that this value matches this type, but infer the most specific type possible." You get the validation of an annotation and the precision of inference.

Especially useful for configuration objects, theme definitions, and route maps:

type Routes = Record<string, { path: string; exact?: boolean }>

const routes = {
  home: { path: '/' },
  about: { path: '/about' },
  profile: { path: '/profile/:id', exact: true },
} satisfies Routes

routes.home.path   // type: '/', not just string
routes.unknown     // ❌ Error, unknown key caught at compile time
Enter fullscreen mode Exit fullscreen mode

Tip 4: Use as const for Literal Types That Don't Drift

as const is one of the most practical tools in TypeScript for working with fixed sets of values. It tells the compiler: "don't widen this, keep every value as its literal type."

The antipattern

// ❌ TypeScript widens these to string[], you lose the specific values
const STATUSES = ['pending', 'active', 'cancelled', 'completed']

type Status = typeof STATUSES[number]  // type: string, not what you wanted
Enter fullscreen mode Exit fullscreen mode

The fix

// ✅ as const preserves literal types
const STATUSES = ['pending', 'active', 'cancelled', 'completed'] as const

type Status = typeof STATUSES[number]
// type: 'pending' | 'active' | 'cancelled' | 'completed' ✅

// The array itself is also typed precisely
const firstStatus = STATUSES[0]  // type: 'pending'
Enter fullscreen mode Exit fullscreen mode

This pattern is particularly powerful for objects used as enums or configuration maps:

const HTTP_CODES = {
  OK: 200,
  CREATED: 201,
  BAD_REQUEST: 400,
  UNAUTHORIZED: 401,
  NOT_FOUND: 404,
  INTERNAL_ERROR: 500,
} as const

type HttpCode = typeof HTTP_CODES[keyof typeof HTTP_CODES]
// type: 200 | 201 | 400 | 401 | 404 | 500

function handleResponse(code: HttpCode) {
  // TypeScript knows exactly which values are valid
}

handleResponse(HTTP_CODES.OK)   // ✅
handleResponse(999)              // ❌ Error: 999 is not assignable to HttpCode
Enter fullscreen mode Exit fullscreen mode

as const also pairs naturally with discriminated unions: define your discriminant values as constants, derive the union type, and you have a single source of truth for both runtime values and compile-time types.


Tip 5: Write Type Guards Instead of Casting

Type casting with as is the TypeScript equivalent of // eslint-disable, it silences the compiler without solving the problem. Every as is a place where TypeScript's safety guarantee ends.

The antipattern

// ❌ Casting tells TypeScript to trust you, and you might be wrong
async function fetchUser(id: string) {
  const response = await fetch(`/api/users/${id}`)
  const data = await response.json()
  return data as User  // TypeScript believes you. The runtime doesn't care.
}
Enter fullscreen mode Exit fullscreen mode

If the API returns a different shape than User, TypeScript won't tell you. The bug surfaces at runtime, in production, when someone accesses user.firstName and gets undefined.

The fix: type guards

// ✅ Validate the shape at runtime, TypeScript trusts what you verify

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'email' in value &&
    'firstName' in value &&
    typeof (value as any).id === 'string' &&
    typeof (value as any).email === 'string'
  )
}

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  const data: unknown = await response.json()

  if (!isUser(data)) {
    throw new Error(`Invalid user response for id: ${id}`)
  }

  return data  // TypeScript now knows this is User, and it's been verified
}
Enter fullscreen mode Exit fullscreen mode

The value is User return type is a type predicate: it tells TypeScript that if the function returns true, the value can be treated as User in the narrowed scope.

For production codebases with complex external data, consider Zod for runtime validation, it generates TypeScript types from schemas and validates data at the boundary in one step. We covered the full pattern in API Calls Done Right.


Tip 6: Use Generics to Write Functions Once

Generics are the feature most developers avoid the longest, and regret avoiding. They're the tool that lets you write a function once and have it work correctly across multiple types, without sacrificing type safety.

The antipattern

// ❌ Three functions doing the same thing for different types
function getFirstUser(items: User[]): User | undefined {
  return items[0]
}

function getFirstProduct(items: Product[]): Product | undefined {
  return items[0]
}

function getFirstOrder(items: Order[]): Order | undefined {
  return items[0]
}
Enter fullscreen mode Exit fullscreen mode

Or worse, using any to avoid repetition:

// ❌ any kills type safety entirely
function getFirst(items: any[]): any {
  return items[0]
}
Enter fullscreen mode Exit fullscreen mode

The fix: generics

// ✅ One function, full type safety across all types
function getFirst<T>(items: T[]): T | undefined {
  return items[0]
}

const firstUser = getFirst(users)      // type: User | undefined
const firstProduct = getFirst(products) // type: Product | undefined
const firstNumber = getFirst([1, 2, 3]) // type: number | undefined
Enter fullscreen mode Exit fullscreen mode

Generics become especially powerful with constraints, limiting which types are accepted:

// Only works with objects that have an id field
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id)
}

findById(users, 'user-1')      // ✅ User has id
findById(products, 'prod-1')   // ✅ Product has id
findById([1, 2, 3], 'x')       // ❌ Error: number doesn't have id
Enter fullscreen mode Exit fullscreen mode

A real-world example, a typed API fetcher:

async function apiFetch<T>(url: string): Promise<T> {
  const response = await fetch(url)
  if (!response.ok) throw new Error(`HTTP error: ${response.status}`)
  return response.json() as Promise<T>
}

// The return type is inferred from the type parameter
const user = await apiFetch<User>('/api/users/1')     // type: User
const products = await apiFetch<Product[]>('/api/products') // type: Product[]
Enter fullscreen mode Exit fullscreen mode

The rule of thumb: when you find yourself writing the same function structure for multiple types, that's a generic waiting to be extracted.


Tip 7: Use ReturnType and Parameters to Stay in Sync

When you call a function from an external library, or from another part of your codebase, you often need types that match its return value or arguments. The naive approach is to manually write those types. The problem is that manual types drift.

The antipattern

// ❌ Manually written type that will drift from the actual function
interface UserServiceResult {
  id: string
  name: string
  // ...but what if userService.getUser() changes?
}

function processUser(user: UserServiceResult) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

When userService.getUser() adds a new field, UserServiceResult doesn't update automatically. You have a type that lies about the actual data.

The fix: ReturnType and Parameters

// ✅ Types derived directly from the functions, always in sync

function getUser(id: string) {
  return {
    id,
    firstName: 'Gavin',
    lastName: 'Cettolo',
    role: 'admin' as const,
    createdAt: new Date().toISOString(),
  }
}

// Automatically matches whatever getUser returns
type User = ReturnType<typeof getUser>

// Automatically matches whatever getUser accepts
type GetUserParams = Parameters<typeof getUser>
// type: [id: string]

function processUser(user: User) {
  // user has exactly the shape of getUser's return value
  // If getUser changes, processUser is immediately aware
  console.log(user.firstName)
}
Enter fullscreen mode Exit fullscreen mode

This is particularly useful when working with third-party libraries where you don't control the type definitions:

import { createStore } from 'some-library'

// Extract the return type without importing a separate type
type Store = ReturnType<typeof createStore>

// Extract what the function expects
type StoreConfig = Parameters<typeof createStore>[0]
Enter fullscreen mode Exit fullscreen mode

Other intrinsic utility types worth knowing alongside these:

  • Awaited<T>, unwraps a Promise type: Awaited<Promise<User>>User
  • InstanceType<T>, extracts the instance type from a constructor

Tip 8: Use unknown Instead of any for External Data

any is TypeScript's escape hatch. It tells the compiler: "I know what I'm doing, stop checking." The problem is that you often don't know, especially with external data.

unknown is the safe alternative. It represents a value you haven't inspected yet. TypeScript forces you to narrow it before you can use it.

The antipattern

// ❌ any propagates silently, errors hide until runtime
function parseConfig(raw: any) {
  return {
    port: raw.port,           // No error even if raw.port doesn't exist
    host: raw.host.trim(),    // Runtime crash if host is undefined
    debug: raw.debug,
  }
}
Enter fullscreen mode Exit fullscreen mode

With any, TypeScript assumes every property access and method call is valid. Errors surface at runtime, not at compile time.

The fix: unknown with narrowing

// ✅ unknown forces you to verify before you use
function parseConfig(raw: unknown): AppConfig {
  if (
    typeof raw !== 'object' ||
    raw === null ||
    !('port' in raw) ||
    !('host' in raw)
  ) {
    throw new Error('Invalid config shape')
  }

  const { port, host } = raw as { port: unknown; host: unknown }

  if (typeof port !== 'number' || typeof host !== 'string') {
    throw new Error('Invalid config field types')
  }

  return { port, host }
}
Enter fullscreen mode Exit fullscreen mode

The places where unknown matters most:

// API responses
const data: unknown = await response.json()

// Error handling, errors thrown in try/catch are unknown
try {
  await riskyOperation()
} catch (error: unknown) {
  if (error instanceof Error) {
    console.error(error.message)  // Safe: TypeScript knows it's an Error
  }
}

// JSON.parse, the return type is any by default, worth narrowing immediately
const parsed: unknown = JSON.parse(rawString)
Enter fullscreen mode Exit fullscreen mode

The rule: use any only when you're in a migration path from untyped code and you need to move fast. For all external data boundaries, APIs, JSON parsing, error handling, use unknown and narrow explicitly.


Honorable Mentions

Three more patterns that didn't make the full treatment but are worth knowing.

Template Literal Types

// Generate precise string types programmatically
type EventName = 'click' | 'focus' | 'blur'
type HandlerName = `on${Capitalize<EventName>}`
// type: 'onClick' | 'onFocus' | 'onBlur'

type ApiEndpoint = `/api/v1/${'users' | 'products' | 'orders'}`
// type: '/api/v1/users' | '/api/v1/products' | '/api/v1/orders'
Enter fullscreen mode Exit fullscreen mode

Especially useful for event handler naming, API route typing, and CSS class generation.

never for Exhaustiveness Checking

// TypeScript will error if you forget to handle a case
function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}

type Shape = 'circle' | 'square' | 'triangle'

function getArea(shape: Shape, size: number): number {
  switch (shape) {
    case 'circle': return Math.PI * size ** 2
    case 'square': return size ** 2
    case 'triangle': return (Math.sqrt(3) / 4) * size ** 2
    default: return assertNever(shape) // Error if a case is missing
  }
}
Enter fullscreen mode Exit fullscreen mode

If you add 'hexagon' to Shape and forget to add a case in the switch, TypeScript will error at the assertNever call. Zero runtime surprises.

Index Signatures Done Right

// ❌ Loose index signature, any string key is valid
interface Config {
  [key: string]: string
}

// ✅ Prefer Record with a union of known keys
type Config = Record<'host' | 'port' | 'database', string>

// Or use satisfies + as const for maximum precision
const config = {
  host: 'localhost',
  port: '5432',
  database: 'myapp',
} satisfies Record<string, string>
Enter fullscreen mode Exit fullscreen mode

Index signatures are sometimes necessary, but when you know the possible keys upfront, Record with a union type gives you better autocomplete and stricter validation.


Final Thoughts

TypeScript's value isn't in the number of features you use. It's in using the right features precisely.

The eight tips in this article share a common thread: they're all about making the type system reflect the real constraints of your domain. Discriminated unions that model actual states. Utility types that derive from a single source of truth. unknown that forces you to verify before you use.

TypeScript that fights you is usually TypeScript that's been used loosely, any where there should be unknown, optional fields where there should be discriminated unions, manual types where there should be inference.

TypeScript that helps you is TypeScript that's been used precisely.

The difference isn't complexity. It's intention.


Which of these tips was new to you, and which one are you already using?

Drop it in the comments. And if there's a TypeScript pattern that's saved you hours in a real project, share it, the best tips in these threads always come from the comments.

If this was useful, a ❤️ or a 🦄 means a lot.
And follow along for the next article in the series.

Top comments (9)

Collapse
 
gavincettolo profile image
Gavin Cettolo • Edited

A couple of clarifications before the comment section inevitably gets there 😄

  • Yes, Zod can be overkill for small projects. If you're validating a handful of inputs, manual type guards are perfectly fine. I included it because in larger codebases it tends to pay for itself quickly, especially when dealing with multiple external data sources.

  • Yes, generics can hurt readability when overused. My rule is simple: write the concrete version first. Only extract a generic once you notice you're repeating the same structure enough times to justify the abstraction.

As with most TypeScript features, the goal isn't to use them everywhere, it's to apply them where they reduce maintenance cost.

Collapse
 
lucaferri profile image
Luca Ferri

This is an important comment. I was exactly thinking to these points

Collapse
 
paolozero profile image
Paolo Zero

You anticipated me 😂

Collapse
 
nyaomaru profile image
nyaomaru

Thanks for the nice article! 😸

Totally agree with your clarification.

I think there’s also a nice middle-ground between fully manual guards and a schema library like Zod: composable type guards.

Start concrete with isUser(value), but once patterns repeat, extract tiny guards like isString, nullable, optional, and, or, etc., and compose them.

That keeps the “write the concrete version first” mindset, while still reducing maintenance cost as the codebase grows.

This is the direction I’ve been exploring with is-kit.
github.com/nyaomaru/is-kit

Collapse
 
elenchen profile image
Elen Chen

You got so many points 😀
Thank you for this clear and easy list

Collapse
 
gavincettolo profile image
Gavin Cettolo

Thank you @elenchen

Collapse
 
nazar_boyko profile image
Nazar Boyko

Quick one on Tip 7. When you derive a type with ReturnType<typeof getUser>, doesn't role come back as the literal 'admin' rather than the 'admin' | 'user' union you'd usually want? The implementation hardcodes 'admin' as const, so the derived type ends up narrower than the real domain, and assigning a 'user' would fail. I lean on deriving types from functions a lot, but that's the one spot where I still reach for an explicit return annotation so the type describes the contract instead of one sample value. The satisfies section was the clearest write-up of that operator I've read, by the way.

Collapse
 
junhao profile image
Ahmed bahar

Hello Cettolo,

I hope you are doing well.

Excellent article.
This is the kind of TypeScript guidance that delivers real value because it focuses on patterns developers actually use in production rather than obscure type tricks.

The explanations around discriminated unions, satisfies, and unknown clearly demonstrate how strong typing can prevent entire classes of bugs before they reach users.

I particularly appreciate the emphasis on precision over complexity great TypeScript is not about writing clever types, but about accurately modeling business logic and maintaining long-term code quality.

Every frontend and full stack developer working with TypeScript can benefit from adopting these practices in their daily workflow.

Collapse
 
frank_signorini profile image
Frank

I've been experimenting with the satisfies operator, but I'm curious about its performance impact - have you noticed any differences in larger projects? I'd love to swap ideas on this.