Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Utility Types Part 2: Readonly and Record

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Utility Types Part 1: Partial and Required
TypeScript
9m
Aug 16, 2026

Utility Types Part 1: Partial and Required

TypeScript ships with a set of built-in generic types — called utility types — that perform common type transformations on existing types. Rather than manually rewriting every property of an object type, you pass your type to a utility type and receive a transformed version. Partial and Required are the two complementary utility types that control whether every property in an object is optional or required.

#Utility Types#Partial+5
Recursive Types in TypeScript
TypeScript
10m
Aug 9, 2026

Recursive Types in TypeScript

Some data structures are inherently self-referential — a tree node that contains other tree nodes, a JSON value that can itself be a JSON object containing more JSON values, a linked list whose nodes point to other nodes of the same type. TypeScript's recursive types handle all of these by allowing a type alias to reference itself in its own definition.

#Recursive Types#TypeScript Advanced Types+5
Template Literal Types in TypeScript
TypeScript
9m
Aug 2, 2026

Template Literal Types in TypeScript

Template literal types bring JavaScript's template literal syntax to the type level. They let you construct new string literal types by combining existing ones — the same way a template literal assembles a string at runtime, but resolved entirely at compile time.

#Template Literal Types#String Manipulation Types+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • What Readonly Does
  • How Readonly Is Implemented
  • What Record Does
  • How Record Is Implemented
  • Practical Patterns for Readonly
  • Practical Patterns for Record
  • Deep Readonly
  • What Is Next
  • Key Takeaways
TypeScript

Utility Types Part 2: Readonly and Record

August 23, 2026•9 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript Readonly and Record utility types for immutable objects and typed dictionaries
Utility Types Part 2: Readonly and Record

The previous post introduced Partial and Required, which transform whether properties are optional or required. This post covers two utility types that work differently: Readonly<T> enforces immutability on an object type by preventing property reassignment, and Record<K, V> constructs a new object type from a set of keys and a value type. Both are shallow, both are built into the TypeScript standard library, and both have distinct use cases that come up repeatedly in everyday TypeScript.

What Readonly Does

Readonly<T> takes an object type T and returns a new type where every property is marked readonly. A readonly property can be read but cannot be reassigned after the object is created.

type User = {
  id: number;
  name: string;
  email: string;
};
 
type ReadonlyUser = Readonly<User>;
// {
//   readonly id: number;
//   readonly name: string;
//   readonly email: string;
// }
 
const user: ReadonlyUser = { id: 1, name: "Alice", email: "[email protected]" };
 
user.name = "Bob"; // Error: Cannot assign to 'name' because it is a read-only property

The readonly modifier prevents reassignment of a property — it does not prevent mutation of the value itself. If a property holds an object or array, Readonly makes the reference read-only (you cannot reassign the property), but the object or array it points to is not frozen.

type Config = Readonly<{
  settings: { theme: string; fontSize: number };
  tags: string[];
}>;
 
const config: Config = {
  settings: { theme: "dark", fontSize: 14 },
  tags: ["a", "b"],
};
 
config.settings = { theme: "light", fontSize: 16 }; // Error: read-only property
config.settings.theme = "light"; // OK — mutates the object, not the reference
config.tags.push("c"); // OK — mutates the array, not the reference

This distinction is critical and often surprises developers encountering Readonly for the first time. The modifier applies at the property level, not the value level.

Readonly Is a Compile-Time Constraint Only

Readonly<T> is a type-level construct that produces compile-time errors when you attempt to reassign a readonly property. It has no runtime enforcement — the JavaScript object is still mutable at runtime, and Object.freeze is a separate, runtime-level operation. Code that bypasses TypeScript's type checking (type assertions, runtime mutation via external code) can still mutate a Readonly typed object. If runtime immutability is required, use Object.freeze in addition to Readonly.

How Readonly Is Implemented

Readonly<T> is defined in the TypeScript standard library as:

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

This is a mapped type that iterates over every key P in keyof T and adds the readonly modifier to each property. The readonly modifier is a mapped type modifier — the same mechanism used by the ? modifier in Partial and Required. Just as -? removes optionality, you could write -readonly to remove the readonly modifier from a type (though no built-in utility does this).

The value types T[P] are preserved exactly. Readonly only adds mutability constraints — it does not change the shape of the object or the types of its values.

What Record Does

Record<K, V> constructs an object type where all keys are of type K and all values are of type V. Unlike Partial, Required, and Readonly, which transform an existing type, Record creates a new object type from scratch.

type StringRecord = Record<string, number>;
// { [key: string]: number }
 
type Status = "active" | "inactive" | "pending";
type StatusMap = Record<Status, boolean>;
// {
//   active: boolean;
//   inactive: boolean;
//   pending: boolean;
// }

When K is a union of string literals, Record produces an object type with exactly those keys. When K is string, it produces an index signature. This distinction matters for how TypeScript checks property access.

const statusMap: Record<Status, boolean> = {
  active: true,
  inactive: false,
  pending: false,
};
 
// TypeScript knows exactly which keys exist:
statusMap.active; // boolean — known property
statusMap.missing; // Error: Property 'missing' does not exist

Contrast this with a plain index signature, where any string key is valid:

const loose: Record<string, boolean> = {};
loose.anything; // boolean | undefined — any key is potentially valid

Record vs Index Signatures

Record<string, V> and { [key: string]: V } are functionally equivalent — both accept any string as a key and return V. The difference is syntactic: Record<string, V> is more readable as a type parameter and communicates intent more clearly. When the key type is a string literal union, Record<K, V> is the idiomatic choice over writing out the full object type manually — it scales automatically when the union is updated.

How Record Is Implemented

Record<K, V> is defined as:

type Record<K extends keyof any, T> = {
  [P in K]: T;
};

This mapped type iterates over every member of K (using P in K) and assigns type T to each key. The constraint K extends keyof any means K must be a valid key type — string, number, symbol, or a union of these. When K is a string literal union, the resulting type has exactly those literal keys. When K is string, the result is an index signature.

The simplicity of the implementation is instructive: Record is just a mapped type that assigns a uniform value type to every key in a set. You could write it inline, but the named utility type communicates dictionary intent clearly.

Practical Patterns for Readonly

Immutable configuration. Configuration objects loaded once at startup should not be mutated after initialisation. Readonly encodes this constraint at the type level:

type AppConfig = Readonly<{
  apiUrl: string;
  timeout: number;
  maxRetries: number;
  featureFlags: Record<string, boolean>;
}>;
 
function initApp(config: AppConfig): void {
  // config.apiUrl = "..."; // Error — cannot reassign
  fetch(config.apiUrl); // Fine — can read
}

Immutable function parameters. Functions that receive an object parameter and must not modify it can use Readonly to communicate and enforce that contract:

function computeTotal(order: Readonly<Order>): number {
  // TypeScript prevents accidental mutation inside this function
  return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

Redux-style state. State objects in flux-pattern implementations should not be mutated in place. Readonly applied to state types makes the immutability contract explicit:

type AppState = Readonly<{
  users: ReadonlyArray<User>;
  currentUserId: number | null;
  loading: boolean;
}>;

ReadonlyArray<T> is the array equivalent of Readonly<T> — it prevents push, pop, splice, and other mutating methods while allowing read access and non-mutating methods like map and filter.

Prefer Readonly on State Types in Reducers

In reducer functions, accepting Readonly<State> as the current state parameter makes it impossible to accidentally return the same mutated object — which is a common bug in hand-written reducers. The compiler will catch any attempt to assign to a state property inside the reducer, forcing you to return a new object rather than mutating the existing one.

Practical Patterns for Record

Lookup tables. When you have a fixed set of keys and need to map each to a value, Record is the natural type:

type HttpStatusCode = 200 | 201 | 400 | 401 | 403 | 404 | 500;
 
const statusMessages: Record<HttpStatusCode, string> = {
  200: "OK",
  201: "Created",
  400: "Bad Request",
  401: "Unauthorised",
  403: "Forbidden",
  404: "Not Found",
  500: "Internal Server Error",
};

If you add a new status code to HttpStatusCode, TypeScript immediately flags the statusMessages object as incomplete — you must add the new entry.

Caches and memoisation. A cache keyed by string IDs with uniform value types is a natural Record<string, V>:

const userCache: Record<string, User> = {};
 
function getUser(id: string): User | undefined {
  return userCache[id];
}

Grouping by discriminant. When partitioning an array of objects by a property, the result type is naturally a Record:

type LogLevel = "info" | "warning" | "error";
 
type LogEntry = { level: LogLevel; message: string; timestamp: Date };
 
function groupByLevel(logs: LogEntry[]): Record<LogLevel, LogEntry[]> {
  const result: Record<LogLevel, LogEntry[]> = {
    info: [],
    warning: [],
    error: [],
  };
  for (const log of logs) {
    result[log.level].push(log);
  }
  return result;
}

The Record<LogLevel, LogEntry[]> return type guarantees that every key in LogLevel is present in the result.

Record Enforces Exhaustiveness

When K is a string literal union, Record<K, V> enforces that every member of the union has an entry in the object. This is the same exhaustiveness guarantee you get from discriminated union switches — adding a new member to the union immediately produces a compile error on any Record<K, V> literal that does not include the new key. This makes Record a useful tool for preventing silent omissions when a union grows.

Deep Readonly

Like Partial, Readonly is shallow — it marks top-level properties as readonly, but properties of nested objects remain mutable through their references.

type Config = Readonly<{
  database: {
    host: string;
    port: number;
  };
}>;
 
const config: Config = { database: { host: "localhost", port: 5432 } };
config.database = { host: "remote", port: 5432 }; // Error — readonly at top level
config.database.host = "remote"; // OK — nested object is still mutable

A recursive DeepReadonly type handles all nesting levels:

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

DeepReadonly is not a built-in utility — you define it yourself. It recurses into arrays (producing ReadonlyArray of deep-readonly items), into objects (marking every key readonly and recursing on values), and leaves primitives unchanged.

For most everyday uses — configuration objects, Redux state shapes — the shallow Readonly is sufficient and the nesting behaviour is handled by convention. DeepReadonly is appropriate when you need a compiler-enforced guarantee that the entire object graph is immutable.

What Is Next

Readonly and Record complete the second pair of built-in utility types. The next post covers Pick and Omit — two complementary types that create focused subsets of an object type by selecting or excluding specific properties by name. Where Partial and Required change optionality across all properties and Readonly changes mutability across all properties, Pick and Omit change which properties are present at all.

Key Takeaways

  • Readonly<T> adds the readonly modifier to every property in T, preventing reassignment; it is implemented as { readonly [P in keyof T]: T[P] }
  • readonly prevents reassigning the property reference — it does not freeze the value; nested objects and arrays are still mutable through their references
  • Record<K, V> constructs an object type with keys K and uniform value type V; when K is a string literal union, the result has exactly those keys and TypeScript enforces exhaustiveness
  • Readonly is useful for immutable configuration, function parameters that must not be modified, and state objects in flux-pattern applications
  • Record is useful for lookup tables, caches, and grouped data structures where a fixed set of keys maps to uniform values
  • Both utility types are shallow; a recursive DeepReadonly type is needed for full depth immutability