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 propertyThe 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 referenceThis 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 existContrast 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 validRecord 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 mutableA 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 thereadonlymodifier to every property inT, preventing reassignment; it is implemented as{ readonly [P in keyof T]: T[P] }readonlyprevents reassigning the property reference — it does not freeze the value; nested objects and arrays are still mutable through their referencesRecord<K, V>constructs an object type with keysKand uniform value typeV; whenKis a string literal union, the result has exactly those keys and TypeScript enforces exhaustivenessReadonlyis useful for immutable configuration, function parameters that must not be modified, and state objects in flux-pattern applicationsRecordis 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
DeepReadonlytype is needed for full depth immutability