Skip to main content
Blog ● 11 min read

Immutable by Default: Practical TypeScript Patterns

I didn’t “go functional.” I just got tired of spooky side-effects and fragile refactors. Immutability wasn’t a Big Rewrite - more like small defaults that kept paying off. This post is how I use it day-to-day: why it helps, how I think about it, and the exact patterns I paste into real code.

Why immutability?

Most of our bugs aren’t clever; they’re incidental. You pass an array into a helper and it quietly pushes. A config object gets tweaked in place during a refactor. A function signature says User[], but the function mutates the input, so your caller’s state changes from the other side of the app.

TypeScript can’t prevent every runtime footgun, but it can make a whole class of them impossible to write. In TS, immutability is a type-level promise: no runtime cost.

  • const prevents rebinding.
  • readonly prevents writes.
  • as const turns literals into precise types.

There’s a nice side-effect: once your values are precise, you can do better type narrowing and you can derive unions from data. That makes APIs self-documenting and refactors safer.

For example:

const createBlogUrl = (slug: string) => `/blog/${slug}` as const
// Return type is `/blog/${string}`, not just `string`.

That tiny as const tells the compiler: “this always starts with /blog/.” Downstream code can use that to narrow or validate.

So what does this look like in practice?

How I approach it

My rule of thumb is: immutable at the edges and relax only where it buys me something.

  • Public APIs accept readonly inputs and return new values. Internals can still mutate local variables if that’s simpler, but the surface area is safe by default.
  • Values first, types from values. I define literal tables with as const, then derive unions from them. There’s a single source of truth.
  • Use satisfies to validate that a value covers a type - without losing the literal precision I get from as const.
  • Don’t be dogmatic. If a local mutation is clearer and doesn’t leak, I’ll do it - then return a new value.

Let’s walk through the patterns that make this work.

Value tables → unions you can trust

Instead of scattering string literals across your code, freeze them in one place and let TypeScript do the heavy lifting.

const FEATURES = ['posts', 'comments', 'users', 'groups'] as const
const OPERATIONS = ['create', 'read', 'update', 'delete'] as const

type Feature = (typeof FEATURES)[number]
type Operation = (typeof OPERATIONS)[number]
type Permission = `${Feature}:${Operation}`

const buildPermissions = (
  features: readonly Feature[],
  operations: readonly Operation[],
): readonly Permission[] =>
  features.flatMap((f) => operations.map((o) => `${f}:${o}` as const))

const PERMISSIONS = buildPermissions(FEATURES, OPERATIONS) satisfies readonly Permission[]

Whenever you add a new feature or operation, the compiler updates the entire permission set. No typos, no missed strings. Just one definition, everywhere consistent.

This pattern scales beyond static permissions. Here’s a real-world example from a Todo app: the filter state that powers both our business logic and the UI tabs.

export const FILTER_STATE = {
  all: 'all',
  done: 'done',
  notDone: 'not-done',
} as const

export type TodoFilter = (typeof FILTER_STATE)[keyof typeof FILTER_STATE]

const filterFunctions = {
  [FILTER_STATE.all]: (todos: Todo[]) => todos,
  [FILTER_STATE.done]: (todos: Todo[]) => todos.filter((t) => t.isDone),
  [FILTER_STATE.notDone]: (todos: Todo[]) => todos.filter((t) => !t.isDone),
} as const satisfies Record<TodoFilter, (todos: Todo[]) => Todo[]>

And then in the UI:

<Tabs.Root value={filter} onValueChange={(d) => onFilterChange(d.value as TodoFilter)}>
  <Tabs.List>
    <Tabs.Trigger value={FILTER_STATE.all}>All</Tabs.Trigger>
    <Tabs.Trigger value={FILTER_STATE.notDone}>Not Done</Tabs.Trigger>
    <Tabs.Trigger value={FILTER_STATE.done}>Done</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value={filter}>{children}</Tabs.Content>
</Tabs.Root>

👉 Why this works so well:

  • Single source of truth. The filter values ('all' | 'done' | 'not-done') live in one place. Add a new filter and the compiler forces you to update the functions and tabs.
  • Types from values. TodoFilter is derived from FILTER_STATE. You can’t pass an invalid string anywhere—TypeScript will catch it.
  • Immutable guarantees. Because the table is as const, the keys and values are frozen. No widening to plain string, no typos slipping through.
  • End-to-end consistency. The same literal drives your state, your filter functions, and your UI. There’s zero chance of your logic and UI drifting apart.

It’s not just about immutability—it’s about locking values, types, and logic together. That’s what makes this pattern so powerful.

Template literal types that carry intent

Once you start treating strings as types, you can encode intent directly in them.

const isBlogUrl = (url: string): url is `/blog/${string}` => url.startsWith('/blog/')

Functions that expect a blog URL can now say so in the signature. That tiny guard helps future maintainers - or future you.

as const, explicit types, and satisfies without the traps

Here’s the trick: let as const infer, and use satisfies to validate. That way you get the best of both worlds: precise types and compile-time checks.

type Hex = '#ff0000' | '#00ff00' | '#0000ff'

const COLORS = ['#ff0000', '#00ff00', '#0000ff']
  as const satisfies readonly Hex[]

const COLOR_MAP = {
  red: '#ff0000',
  green: '#00ff00',
  blue: '#0000ff',
} as const satisfies Record<string, Hex>

This avoids accidental widening and keeps both values and types locked in sync.

Gotcha

Be cautious when using the satisfies operator with arrays.
The TypeScript documentation says:

The new satisfies operator lets us validate that the type of an expression matches some type, without changing the resulting type of that expression.

However, this isn’t entirely true. Let’s look at three simple examples:

const COLORS = ['#ff0000', '#00ff00', '#0000ff'] as const
// 1) ?^ const COLORS: readonly ["#ff0000", "#00ff00", "#0000ff"]

const COLORS = ['#ff0000', '#00ff00', '#0000ff'] as const satisfies Hex[]
// 2) ?^ const COLORS: ["#ff0000", "#00ff00", "#0000ff"]

const COLORS = ['#ff0000', '#00ff00', '#0000ff'] as const satisfies readonly Hex[]
// 3) ?^ const COLORS: readonly ["#ff0000", "#00ff00", "#0000ff"]

Notice the difference?

As soon as we add satisfies after as const without readonly (example 2), the readonly qualifier is dropped from the type.

💡 So whenever you use as const satisfies with an array, remember to explicitly add readonly if you don’t want to lose it in the type signature.

Function signatures that say “I won’t mutate your stuff”

Marking inputs readonly is a type-level promise: “This function won’t mess with your data.”

const sum = (values: readonly number[]): number =>
  values.reduce((total, v) => total + v, 0)

Same implementation as a mutable version, but infinitely clearer at the call site.

What’s more, readonly removes mutating methods from the type. You won’t even see push, sort, or splice in autocomplete, because those would break the promise. This forces you to:

  • Copy first if you really need to mutate (const copy = [...values]).
  • Or better: reach for the immutable ES2023 array methods like toSorted, toSpliced, or toReversed.
const sorted = values.toSorted() // immutable alternative to sort()
const withoutFirst = values.toSpliced(0, 1) // immutable splice
const reversed = values.toReversed() // immutable reverse

That’s the real win: the type system nudges you into writing pure functions and makes mutation an explicit choice, not an accident.

And when you expose collections, return immutable views:

const internalCache = new Map<string, number>()

export function getCache(): ReadonlyMap<string, number> {
  return internalCache
}

Internally flexible, externally safe.

Updating state without mutation

The real test of immutability is when you need to change something. The answer isn’t “never change” - it’s “return a new value instead of mutating the old one.”

type Todo = { id: string; title: string; isDone: boolean }

const toggleTodo = (todos: readonly Todo[], id: Todo['id']): readonly Todo[] =>
  todos.map((t) => (t.id === id ? { ...t, isDone: !t.isDone } : t))

const upsertById = <T extends { id: string | number }>(
  items: readonly T[],
  item: T,
): readonly T[] => {
  const index = items.findIndex((i) => i.id === item.id)
  return index === -1 ? items.concat(item) : items.with(index, item)
}

Pure functions. Clean diffs. No spooky side effects.

Deep immutability (only when you really need it)

When you use as const, the entire value becomes deeply immutable—no matter how many levels of nesting there are.

In contrast, Readonly<T> only makes the top-level properties immutable. Nested objects remain mutable unless you explicitly wrap them in Readonly<...> as well.

// `Readonly<T>` is shallow: only top-level properties are readonly.
const user: Readonly<{
  name: string
  profile: { greeting: string } // <- nested object is NOT readonly
}> = {
  name: 'Alice',
  profile: { greeting: 'Hello' },
}

user.name = 'Bob'
// ^ Error: `user.name` is readonly (top-level)

user.profile = { greeting: 'Hi' }
// ^ Error: `user.profile` is readonly as a top-level property (can’t reassign the object)

user.profile.greeting = 'Hola'
// ^ OK: `profile` itself is readonly, but its inner fields are still mutable

In practice, shallow immutability is usually enough. But if you need deep immutability, you can define a utility type instead of wrapping each level in Readonly<T> manually:

type DeepReadonly<T> = T extends (...args: unknown[]) => unknown
  ? T
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T

Exhaustive unions that fail loudly

When you pair immutability with discriminated unions, the compiler won’t let you forget a case.

type Msg =
  | { kind: 'info'; text: string }
  | { kind: 'success'; text: string }
  | { kind: 'warning'; text: string; code: number }
  | { kind: 'error'; text: string; stack?: string }

const renderMessage = (m: Readonly<Msg>) => {
  switch (m.kind) {
    case 'info':
    case 'success':
      return m.text
    case 'warning':
      return `${m.code}: ${m.text}`
    case 'error':
      return m.stack ?? m.text
    default: {
      const _exhaustive: never = m
      return _exhaustive
    }
  }
}

Compile-time guarantees, runtime peace of mind.

Interop with Validators

Immutable tuples don’t just help TypeScript; they also make runtime validation a breeze.

import { z } from 'zod'

const STATUSES = ['draft', 'published', 'archived'] as const

export type Status = (typeof STATUSES)[number]
export const StatusSchema = z.enum(STATUSES)

One definition, two worlds covered: compile-time types and runtime checks. No drifting copies, no sync issues.

Wrapping up

Immutability in TypeScript isn’t dogma. It’s just a set of defaults that turn “hope this doesn’t mutate” into “can’t mutate even if I try.”

Start with small steps:

  • Define literals with as const.
  • Accept readonly inputs.
  • Derive unions from values.

From there, you’ll notice cleaner diffs, safer refactors, and APIs that explain themselves.

And when a local mutation is the clearest option? Do it - but keep the boundary immutable. That’s the whole game: predictable code people enjoy maintaining.