TypeScript ReadonlyMap and ReadonlySet in 2026 - Immutable Collections That Stop Accidental Mutation at the Type Level
Learn how TypeScript's ReadonlyMap and ReadonlySet prevent accidental mutations at compile time, when to use them over Record and Map, and how to build type-safe cache APIs and configuration systems that enforce immutability.
Why ReadonlyMap and ReadonlySet Matter in 2026
Most runtime bugs in TypeScript codebases stem from unintended mutation of shared data structures. A cache gets modified during a read operation. A configuration set changes after initialization. A lookup map loses entries when a function meant to query it accidentally calls .delete(). The type system allows these mutations because developers reach for Map and Set everywhere, even when the collection should never change after construction.
The solution exists at the type level. ReadonlyMap<K, V> and ReadonlySet<T> strip mutating methods from the interface, making accidental writes a compile-time error instead of a production incident. Teams that adopt these types as the default for exposed collections catch entire classes of bugs before code review.
%% alt: Problem flow showing accidental mutation passing type check
flowchart LR
A("Expose Map from cache") --> B("Function receives Map")
B --> C("Accidental .delete() call")
C --> D("Type system stays silent")
D --> E("Runtime cache corruption")
style E stroke:#ef4444,fill:#450a0a,color:#fca5a5
The correct pattern flips this. Return ReadonlyMap from any function that provides a view into shared state. The compiler blocks mutation attempts immediately, and the intent becomes explicit in the signature.
%% alt: Solution flow showing compile-time prevention
flowchart LR
A("Expose ReadonlyMap from cache") --> B("Function receives ReadonlyMap")
B --> C("Attempted .delete() call")
C --> D("Compiler throws error")
D --> E("Bug caught before merge")
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This matters because the cost of a production cache corruption or a silently modified feature flag set is orders of magnitude higher than the friction of adding readonly to a type annotation. The pattern scales: once teams default to readonly collections, mutation becomes an explicit opt-in, and the codebase self-documents which functions preserve state versus transform it.
Key Takeaways
ReadonlyMapandReadonlySetremove mutating methods at the type level, preventing accidental writes to shared collections.- Returning readonly collections from functions makes immutability explicit in the signature and catches bugs at compile time.
- The type-level protection has zero runtime cost but does not prevent mutation if the underlying reference is cast back to a mutable type.
- Use
ReadonlyMapfor cache APIs and lookup tables; useReadonlySetfor configuration flags and whitelists that initialize once. - Runtime immutability requires defensive copies or immutable data structure libraries; TypeScript's
readonlyis a compile-time guard only.
The Type-Level Contract: What Readonly Actually Enforces
ReadonlyMap<K, V> is a type that omits .set(), .delete(), and .clear() from the Map interface. A function that receives a ReadonlyMap can call .get(), .has(), .keys(), .values(), .entries(), and .forEach(), but any attempt to mutate the collection fails at compile time.
The compiler enforces this contract through structural typing. When a function signature declares a ReadonlyMap parameter, TypeScript checks that the argument provides the readonly methods and lacks the mutating ones. This happens at the type level only. At runtime, a ReadonlyMap is still a Map instance. The protection disappears if the value is cast back to Map or if JavaScript code bypasses the type system.
%% alt: Type-level contract hierarchy
flowchart TD
A("Map interface") --> B("Mutating methods: set, delete, clear")
A --> C("Read methods: get, has, size, forEach")
D("ReadonlyMap interface") --> C
D -.->|"omits"| B
style D stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The same structure applies to ReadonlySet<T>. It omits .add(), .delete(), and .clear() but retains .has(), .size, .keys(), .values(), .entries(), and .forEach(). The type system prevents mutation wherever the readonly variant appears.
This distinction is critical. The readonly types are not runtime wrappers or proxies. They are compile-time views that restrict the available operations. A Map can be passed where a ReadonlyMap is expected because it satisfies the readonly interface (it has all the read methods). The reverse does not hold: a ReadonlyMap cannot be passed where a Map is expected because it lacks the mutation methods.
The practical consequence is that functions should accept the narrowest type that supports their operations. If a function only queries a map, its parameter should be ReadonlyMap. If it must mutate the map, the parameter must be Map. The signature communicates intent, and the compiler enforces it.
Building Immutable Cache APIs with ReadonlyMap
A cache that exposes a mutable Map invites accidental corruption. The correct pattern returns a ReadonlyMap view while maintaining a private mutable instance for internal updates.
class UserCache {
private cache = new Map<string, User>();
set(id: string, user: User): void {
this.cache.set(id, user);
}
getAll(): ReadonlyMap<string, User> {
return this.cache;
}
get(id: string): User | undefined {
return this.cache.get(id);
}
clear(): void {
this.cache.clear();
}
}
const cache = new UserCache();
cache.set("u1", { id: "u1", name: "Alice" });
const snapshot = cache.getAll();
// snapshot.set("u2", { id: "u2", name: "Bob" }); // Compiler error
// snapshot.delete("u1"); // Compiler error
const user = snapshot.get("u1"); // WorksThe getAll() method returns the internal map typed as ReadonlyMap. No defensive copy is needed because the type system prevents external mutation. The cache controls when writes happen through explicit methods like .set() and .clear(). Consumers receive a view that guarantees the cache state will not change underneath their logic.
This pattern extends to more complex scenarios. A query result cache that aggregates data from multiple sources can return a readonly snapshot. A configuration loader that reads from a file can expose the parsed values as a ReadonlyMap, ensuring that application code cannot accidentally overwrite settings.

The failure mode here is subtle but expensive. If getAll() returned Map<string, User>, a caller could write cache.getAll().clear(), wiping the entire cache without triggering a type error. The bug might surface minutes or hours later when a lookup fails. The readonly return type makes this impossible at compile time.
The tradeoff is that consumers must explicitly call mutating methods on the cache instance itself. Code that needs to modify the cache cannot do so through the snapshot. This friction is the design goal: mutation becomes an intentional action on a controlled API surface.
ReadonlySet for Configuration and Feature Flags
Feature flags and configuration whitelists initialize once and should never change during the application lifecycle. A ReadonlySet enforces this constraint at the type level.
class FeatureFlags {
private flags: Set<string>;
constructor(enabledFlags: string[]) {
this.flags = new Set(enabledFlags);
}
getEnabled(): ReadonlySet<string> {
return this.flags;
}
isEnabled(flag: string): boolean {
return this.flags.has(flag);
}
}
const features = new FeatureFlags(["beta-ui", "analytics-v2"]);
const enabled = features.getEnabled();
if (enabled.has("beta-ui")) {
// Render new UI
}
// enabled.add("experimental-mode"); // Compiler error
// enabled.delete("analytics-v2"); // Compiler errorThe getEnabled() method returns a ReadonlySet view. Application code can check membership but cannot add or remove flags. Changes to the feature set require restarting the application or reloading configuration through a controlled mechanism.
%% alt: Feature flag system flow with readonly enforcement
flowchart LR
A("Initialize FeatureFlags") --> B("Private Set created")
B --> C("getEnabled returns ReadonlySet")
C --> D("Application code checks membership")
D --> E("Mutation attempts blocked")
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This pattern applies to any configuration that loads once and stays constant. A whitelist of allowed origins for CORS. A set of valid API versions. A registry of registered plugin names. The readonly return type communicates that these values are fixed after initialization.
The alternative is to return Set and trust that callers will not mutate it. That trust breaks when a developer unfamiliar with the system writes features.getEnabled().add() in a new feature branch. The type system catches this immediately if the signature returns ReadonlySet.
The same principle extends to more complex configuration objects. A settings object can use ReadonlyMap<string, ConfigValue> to expose parsed values while keeping the underlying map private. The type system prevents accidental overwrites of critical settings like database URLs or API keys.
ReadonlyMap vs Record vs Map: Choosing the Right Structure
Three structures store key-value pairs in TypeScript: Map, Record, and ReadonlyMap. Each has a distinct use case.
Use Map when keys are not known at compile time and mutation is required. A cache that adds entries dynamically. A dependency graph that builds incrementally. The structure needs .set(), .delete(), and .clear().
Use Record<K, V> when keys are known at compile time and the structure is a plain object. Configuration parsed from JSON. An enum-to-metadata lookup. The values are fixed or updated through object spread. Records benefit from exact property checking and do not require runtime Map instantiation.
Use ReadonlyMap when exposing a view of a mutable map that external code should not modify. Cache snapshots. Query result lookups. The structure provides efficient iteration and membership tests without risking accidental mutation.
%% alt: Comparison of Map, Record, and ReadonlyMap use cases
flowchart LR
subgraph Map["Mutable Map"]
A("Dynamic keys") --> B("Requires mutation")
B --> C("Internal state management")
end
subgraph Record["Record Type"]
D("Static keys") --> E("Plain object structure")
E --> F("Exact property checking")
end
subgraph ReadonlyMap["ReadonlyMap View"]
G("Dynamic keys") --> H("No mutation allowed")
H --> I("Safe external exposure")
end
style ReadonlyMap stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style Map stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style Record stroke:#7c9cf0,fill:#142544,color:#eaf2ff
The performance characteristics differ. Map uses hashing for lookups and insertion. Record uses property access, which V8 optimizes aggressively for objects with fixed shapes. ReadonlyMap has the same performance as Map because it is a Map at runtime. The type annotation has no runtime cost.
The immutability guarantee also differs. Record types are mutable by default unless marked with Readonly<Record<K, V>>. Even then, TypeScript's Readonly only makes top-level properties readonly. Nested objects remain mutable. ReadonlyMap is a shallow constraint: the map itself cannot be mutated, but the values it holds can be if they are mutable types.
The implication here is that ReadonlyMap<string, User[]> prevents adding or removing entries, but it does not prevent pushing elements into the arrays stored as values. If deep immutability is required, the value type must also be readonly: ReadonlyMap<string, readonly User[]>.
This tradeoff matters in practice. A cache returning ReadonlyMap<string, MutableObject> prevents callers from replacing entire entries but allows modification of the objects themselves. If that is unacceptable, the cache must return defensive copies or use an immutable data structure library.
Runtime Immutability: When TypeScript's readonly Isn't Enough
TypeScript's readonly modifier and ReadonlyMap are compile-time constructs. They do not enforce immutability at runtime. A value typed as ReadonlyMap can be cast to Map with as Map, bypassing the readonly constraint entirely. JavaScript code that interacts with TypeScript modules sees only runtime maps with no readonly protection.
function dangerousOperation(cache: ReadonlyMap<string, number>): void {
// Type assertion removes compile-time protection
const mutableCache = cache as Map<string, number>;
mutableCache.set("attack", 999);
}
const cache = new Map<string, number>([["key", 1]]);
dangerousOperation(cache);
console.log(cache.get("attack")); // 999This is not a type system flaw. TypeScript assumes developers will not use type assertions to subvert safety guarantees. The readonly types are a contract that the compiler enforces, but they are not a security boundary.
%% alt: Compile-time vs runtime immutability layers
flowchart TD
A("TypeScript readonly types") --> B("Compile-time checks")
B --> C("Type assertions bypass safety")
D("Runtime immutability") --> E("Defensive copies")
D --> F("Immutable libraries")
D --> G("Object.freeze()")
C --> H("Mutation succeeds at runtime")
E --> I("Full immutability guarantee")
style C stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style I stroke:#34d399,fill:#0b3b2e,color:#d1fae5
If runtime immutability is required, three strategies apply. First, return defensive copies. Clone the map before returning it, so mutations affect only the copy. Second, use an immutable data structure library like Immutable.js or Immer, which provides collections that cannot be mutated even at runtime. Third, use Object.freeze() on plain objects to prevent property changes, though this does not work on maps or sets.
Defensive copying has a performance cost. Cloning a large map on every read scales poorly. Immutable libraries add a dependency and require learning a new API. Object.freeze() is shallow and does not prevent nested mutations.
The pragmatic approach is to treat TypeScript's readonly types as the primary defense and add runtime protection only where necessary. A public API that crosses a trust boundary should return defensive copies. Internal code within a controlled codebase can rely on readonly types and code review to prevent misuse.
The key distinction is that readonly types catch accidental mutation by well-intentioned developers. Runtime immutability defends against adversarial code or JavaScript interop where the type system cannot help.
Common Pitfalls and How to Avoid Them
The most common mistake is forgetting that ReadonlyMap is a shallow constraint. Developers assume that returning ReadonlyMap<string, Config> prevents all mutation, but if Config is a mutable object, its properties can still change.
interface Config {
apiKey: string;
timeout: number;
}
const configs = new Map<string, Config>([
["prod", { apiKey: "secret", timeout: 5000 }],
]);
function getConfigs(): ReadonlyMap<string, Config> {
return configs;
}
const snapshot = getConfigs();
// snapshot.set("dev", { apiKey: "test", timeout: 1000 }); // Error
const prodConfig = snapshot.get("prod");
if (prodConfig) {
prodConfig.timeout = 10000; // No error, modifies original
}The fix is to make the value type readonly as well: ReadonlyMap<string, Readonly<Config>>. This prevents mutation of both the map and the objects it contains.
%% alt: Shallow vs deep readonly protection
flowchart LR
A("ReadonlyMap declared") --> B("Map entries protected")
B --> C("Value objects mutable")
C --> D("Silent property mutation")
E("ReadonlyMap with Readonly values") --> F("Map entries protected")
F --> G("Value properties protected")
G --> H("Full immutability")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style H stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The second pitfall is returning a readonly view from a function but storing the result in a mutable variable. The type system allows assigning a ReadonlyMap to a Map variable if the declaration uses let without an explicit type annotation.
function getCache(): ReadonlyMap<string, number> {
return new Map([["key", 1]]);
}
let cache = getCache(); // Type inferred as ReadonlyMap
cache.set("new", 2); // Compiler error
let mutableCache: Map<string, number> = getCache() as Map<string, number>;
mutableCache.set("new", 2); // Works but bypasses readonly intentThe solution is to always annotate variables that hold readonly collections with the readonly type. Do not rely on type inference to preserve the constraint.

The third pitfall is using readonly types inconsistently across a codebase. One module returns Map, another returns ReadonlyMap, and developers cannot tell which functions might mutate shared state. The fix is to establish a convention: all functions that expose collections return readonly types unless mutation is explicitly part of the API contract.
This consistency matters. When readonly types are the default, a function signature that returns Map signals intent: this collection is meant to be mutated. The type system becomes documentation that the compiler enforces.
Frequently Asked Questions
Does ReadonlyMap have any runtime overhead compared to Map?
ReadonlyMap is a type annotation only. At runtime, a ReadonlyMap is a Map instance with no wrapper, proxy, or performance cost. The readonly constraint exists only at compile time.
Can I convert a Map to a ReadonlyMap without copying it?
Yes. A Map can be directly returned or assigned where a ReadonlyMap is expected because it satisfies the readonly interface structurally. No copying or conversion is needed.
How do I handle nested mutable objects inside a ReadonlyMap?
Use Readonly<T> recursively on the value type: ReadonlyMap<string, Readonly<Config>>. For deeply nested structures, consider a library like ts-essentials that provides DeepReadonly.
Why would I use ReadonlySet instead of a readonly array?
ReadonlySet provides O(1) membership checks with .has(), while arrays require O(n) iteration with .includes(). Use sets for large collections where membership testing is frequent.
Can I use ReadonlyMap with the new TypeScript 5.x features?
Yes. ReadonlyMap and ReadonlySet work seamlessly with features like as const, satisfies, and the branded types pattern covered in TypeScript branded types.
Immutable Collections as Part of a Broader Type Safety Strategy
ReadonlyMap and ReadonlySet are tools, not solutions. They prevent accidental mutation at compile time but do not eliminate all risks. A codebase that adopts these types must also establish conventions for when mutation is acceptable, how to handle runtime immutability requirements, and how to integrate with third-party code that expects mutable collections.
The broader pattern is to default to immutability and opt into mutation explicitly. Function parameters should use readonly types unless the function must modify the collection. Return types should expose readonly views unless the caller is expected to mutate the result. This approach scales because it makes mutation visible in the type signature.
The same principle applies to other TypeScript features. Use as const for configuration objects. Use Readonly<T> for interfaces that represent immutable state. Use branded types to prevent accidental mixing of semantically different values. Each feature reinforces the others, and the type system becomes a defense-in-depth strategy against entire classes of bugs.
Related patterns include typed routes in Next.js for compile-time URL safety and as const with satisfies for narrow literal types. These techniques combine to create codebases where invalid states are unrepresentable at the type level.
That covers the essential patterns for using ReadonlyMap and ReadonlySet in production TypeScript. Apply these in your cache APIs, configuration systems, and shared state management, and the difference will be immediate. The bugs that never reach production because the compiler caught them are the ones that matter most.