Skip to main content
Advertisement

17.4 Immutable Data Patterns — Deep readonly and Immer + TypeScript

readonly and as const

// Shallow readonly
interface Config {
readonly host: string
readonly port: number
readonly options: { // This nested object is NOT readonly!
timeout: number
retries: number
}
}

const config: Config = {
host: 'localhost',
port: 3000,
options: { timeout: 5000, retries: 3 },
}

config.host = 'new-host' // Error: readonly ✅
config.options.timeout = 1000 // Allowed: nested object is mutable ❌

// as const — deep immutability
const CONFIG = {
host: 'localhost',
port: 3000,
options: {
timeout: 5000,
retries: 3,
},
} as const

// typeof CONFIG:
// {
// readonly host: "localhost";
// readonly port: 3000;
// readonly options: {
// readonly timeout: 5000;
// readonly retries: 3;
// };
// }

DeepReadonly Utility Type

// Recursive readonly type
type DeepReadonly<T> =
T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T

interface AppState {
users: User[]
settings: {
theme: 'light' | 'dark'
notifications: {
email: boolean
push: boolean
}
}
}

type ReadonlyAppState = DeepReadonly<AppState>

const state: ReadonlyAppState = {
users: [],
settings: {
theme: 'dark',
notifications: { email: true, push: false },
},
}

state.users.push({ id: '1' } as any) // Error: readonly array ✅
state.settings.theme = 'light' // Error: readonly ✅
state.settings.notifications.email = false // Error: readonly ✅

Immer — Simplified Immutable Updates

npm install immer
import { produce, Draft } from 'immer'

interface AppState {
users: User[]
selectedUserId: string | null
loading: boolean
}

const initialState: AppState = {
users: [],
selectedUserId: null,
loading: false,
}

// Immer produce — modifies draft and returns new object
const nextState = produce(initialState, (draft: Draft<AppState>) => {
draft.users.push({
id: '1',
name: 'Alice',
email: 'alice@example.com',
role: 'user',
createdAt: new Date(),
updatedAt: new Date(),
})
draft.selectedUserId = '1'
})

console.log(initialState.users.length) // 0 — original unchanged
console.log(nextState.users.length) // 1 — new object

// Curried producers
const addUser = produce((draft: Draft<AppState>, user: User) => {
draft.users.push(user)
})

const updateUser = produce((draft: Draft<AppState>, id: string, updates: Partial<User>) => {
const user = draft.users.find(u => u.id === id)
if (user) Object.assign(user, updates)
})

const removeUser = produce((draft: Draft<AppState>, id: string) => {
const index = draft.users.findIndex(u => u.id === id)
if (index !== -1) draft.users.splice(index, 1)
})

// Use with React useState
const [state, setState] = useState(initialState)

const handleAddUser = (user: User) => {
setState(addUser(user)) // Type-safe, immutable update
}

Redux Toolkit + Immer

import { createSlice, PayloadAction } from '@reduxjs/toolkit'
// Redux Toolkit uses Immer internally

interface UsersState {
items: User[]
selectedId: string | null
status: 'idle' | 'loading' | 'success' | 'error'
error: string | null
}

const initialState: UsersState = {
items: [],
selectedId: null,
status: 'idle',
error: null,
}

const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
// Can directly mutate Immer draft
addUser(state, action: PayloadAction<User>) {
state.items.push(action.payload) // Immutable update handled automatically
},
updateUser(state, action: PayloadAction<{ id: string; updates: Partial<User> }>) {
const user = state.items.find(u => u.id === action.payload.id)
if (user) Object.assign(user, action.payload.updates)
},
removeUser(state, action: PayloadAction<string>) {
state.items = state.items.filter(u => u.id !== action.payload)
},
selectUser(state, action: PayloadAction<string | null>) {
state.selectedId = action.payload
},
},
})

Immutable Updates with Record and Map

// Normalized state management using Record instead of arrays
interface NormalizedState {
byId: Record<string, User>
allIds: string[]
}

// Immutable add
function addUserToState(state: NormalizedState, user: User): NormalizedState {
return {
byId: { ...state.byId, [user.id]: user },
allIds: [...state.allIds, user.id],
}
}

// Immutable update
function updateUserInState(
state: NormalizedState,
id: string,
updates: Partial<User>
): NormalizedState {
return {
...state,
byId: {
...state.byId,
[id]: { ...state.byId[id], ...updates },
},
}
}

// Immutable delete
function removeUserFromState(state: NormalizedState, id: string): NormalizedState {
const { [id]: _, ...remainingById } = state.byId
return {
byId: remainingById,
allIds: state.allIds.filter(existingId => existingId !== id),
}
}

Pro Tips

Enforce Runtime Immutability with Object.freeze

function deepFreeze<T extends object>(obj: T): Readonly<T> {
Object.getOwnPropertyNames(obj).forEach(name => {
const value = (obj as any)[name]
if (typeof value === 'object' && value !== null) {
deepFreeze(value)
}
})
return Object.freeze(obj)
}

const config = deepFreeze({
api: {
baseUrl: 'https://api.example.com',
timeout: 5000,
},
})

config.api.baseUrl = 'https://other.com' // Runtime error in strict mode
Advertisement