# TypeScript `ReadonlyMap` and `ReadonlySet` in 2026: Immutable Collections That Stop Accidental Mutation at the Type Level

> Source: <https://dev.to/jsmanifest/typescript-readonlymap-and-readonlyset-in-2026-immutable-collections-that-stop-accidental-2ida>
> Published: 2026-10-15 06:06:15+00:00

`ReadonlyMap` and `ReadonlySet` in 2026: Immutable Collections That Stop Accidental Mutation at the Type Level
*This article was written with the assistance of AI, under human supervision and review.*

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.

*Problem flow showing accidental mutation passing type check*

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.

*Solution flow showing compile-time prevention*

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.

`readonly` is a compile-time guard only.
`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.

*Type-level contract hierarchy*

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.

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"); // Works
```

The `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.

*Cache API returning ReadonlyMap for safe external access*

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.

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 error
```

The `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.

*Feature flag system flow with readonly enforcement*

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.

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.

*Comparison of Map, Record, and ReadonlyMap use cases*

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.

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")); // 999
```

This 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.

*Compile-time vs runtime immutability layers*

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.

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.

``` js
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.

*Shallow vs deep readonly protection*

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 intent
```

The solution is to always annotate variables that hold readonly collections with the readonly type. Do not rely on type inference to preserve the constraint.

*Type annotation preserving readonly constraints in variable declarations*

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.

`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.

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.

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`.

`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.

Yes. `ReadonlyMap` and `ReadonlySet` work seamlessly with features like `as const`, `satisfies`, and the branded types pattern covered in [TypeScript branded types](https://jsmanifest.com/typescript-branded-types-nominal-typing).

`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](https://jsmanifest.com/nextjs-typed-routes-2026) for compile-time URL safety and [`as const` with `satisfies`](https://jsmanifest.com/typescript-as-const-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.
