DEV Community

Cover image for Deep Readonly Generic in Typescript
Volodymyr Yepishev
Volodymyr Yepishev

Posted on • Updated on

Deep Readonly Generic in Typescript

The image is how NightCafe sees deep readonly type.
The link to the playground for this article is down below.

Immutability could be useful for certain cases when building applications, and today we will take a look how to enforce immutability using Typescript.

Normally to get an immutable object, it would need to be frozen with the appropriate JavaScript function, but that would only freeze a single nesting level of it and would not affect anything nested inside, whether in its objects inside properties or arrays of objects.

A possible solution would be to iterate over all properties recursively, freezing all objects and repeating the process for any nested arrays of objects.

However, Typescript provides means that can potentially eliminate this need if used wisely.

Suppose we have the following interfaces representing a family:

interface Person {
  firstName: string;
  lastName: string;
  age: number;
  children?: Person[];
}

interface Family {
  parents: Person[];
  grandparents?: {
    paternal: Person[];
    maternal: Person[];
  };
}
Enter fullscreen mode Exit fullscreen mode

And a corresponding variable representing the family instance:

const family: Family = {
  parents: [
    { firstName: "John", lastName: "Doe", age: 40 },
    { firstName: "Jane", lastName: "Doe", age: 38 },
  ],
  grandparents: {
    paternal: [
      { firstName: "PaternalGrandfather", lastName: "Doe", age: 70 },
      { firstName: "PaternalGrandmother", lastName: "Doe", age: 68 },
    ],
    maternal: [
      { firstName: "MaternalGrandfather", lastName: "Smith", age: 75 },
      { firstName: "MaternalGrandmother", lastName: "Smith", age: 72 },
    ],
  }
};
Enter fullscreen mode Exit fullscreen mode

In order to provide means of immutability, we could write a generic, which recursively traverses and interface field and marks everything it encounters as Readonly, essentially mimicking the freezing, but eliminating the fuss of the actual deep freeze.

type DeepReadonly<T> = Readonly<{
  [K in keyof T]: 
    // Is it a primitive? Then make it readonly
    T[K] extends (number | string | symbol) ? Readonly<T[K]> 
    // Is it an array of items? Then make the array readonly and the item as well
    : T[K] extends Array<infer A> ? Readonly<Array<DeepReadonly<A>>> 
    // It is some other object, make it readonly as well
    : DeepReadonly<T[K]>;
}>
Enter fullscreen mode Exit fullscreen mode

There, now we can create objects, which can be real constants:

const family2: DeepReadonly<Family> = {
  parents: [
    { firstName: "John", lastName: "Doe", age: 40 },
    { firstName: "Jane", lastName: "Doe", age: 38 },
  ],
  grandparents: {
    paternal: [
      { firstName: "PaternalGrandfather", lastName: "Doe", age: 70 },
      { firstName: "PaternalGrandmother", lastName: "Doe", age: 68 },
    ],
    maternal: [
      { firstName: "MaternalGrandfather", lastName: "Smith", age: 75 },
      { firstName: "MaternalGrandmother", lastName: "Smith", age: 72 },
    ],
  }
};
Enter fullscreen mode Exit fullscreen mode

Any changes to the object typed with the generic are going to be stopped by the compiler:

family.parents = []; // ok
family2.parents = []; // error

family.parents[0].age = 1; // ok
family2.parents[0].age = 1; // error

// ok
family.parents.push({
  age: 40,
  firstName: 'Joseph',
  lastName: 'Doe'
});

// error
family2.parents.push({
  age: 40,
  firstName: 'Joseph',
  lastName: 'Doe'
});
Enter fullscreen mode Exit fullscreen mode

All benefits from Object.freeze without a single freeze, cool, eh? At this point you are probably wondering how to shoot yourself in the foot with it, there should be a way.

And there is a way indeed, shooting in the foot is possible using reference types:

const family3: DeepReadonly<Family> = family;
Enter fullscreen mode Exit fullscreen mode

As you remember, family is just Family, so any changes to it would mutate family3, even though it is deep readonly.

This is the way things are.

Hope you enjoyed the article as much as I did while researching this :)

The playground.

P.S. if someone knows how to pull this trick with generics in JSDoc, please post it in the comments :)
P.P.S. JSDoc conversion by @artxe2 so I don't lose it.

Top comments (7)

Collapse
 
grief profile image
Grief

Your code works incorrectly for tuple types:

const works: DeepReadonly<['one'|'two', 1 | 2]> = [1, 2]; // TS2322: Type 'number' is not assignable to type 'Readonly<"one" | "two">'.
const doesntwork: DeepReadonly<{x: ['one'|'two', 1 | 2], y: string}> = {x: [1, 2], y: 'asd'}; // no error
Enter fullscreen mode Exit fullscreen mode
Collapse
 
bwca profile image
Volodymyr Yepishev

Apparently it's the inferring the array type and passing it back to the Array generic that messes up the case for tuples. Turns out this step can be avoided by passing the property type directly, then the generic gets even smaller:

type DeepReadonly<T> = Readonly<{
  [K in keyof T]: 
    T[K] extends (number | string | symbol) ? Readonly<T[K]>
    : Readonly<DeepReadonly<T[K]>>;
}>
Enter fullscreen mode Exit fullscreen mode

Here's the playground with the tuple example you provided.

Collapse
 
grief profile image
Grief • Edited

I don't get, what's the benefit over this simpler variant?

type DeepReadonly<T> = {
  readonly [K in keyof T]: DeepReadonly<T[K]>;
};
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
bwca profile image
Volodymyr Yepishev

I don't see any apparent advantages, this one seems less verbose 🤔

Collapse
 
bwca profile image
Volodymyr Yepishev

That's a good observation, I haven't accounted for the tuples when designing it, thanks 👍

Perhaps, I'll revise it 🤓

Collapse
 
danielearwicker profile image
Daniel Earwicker • Edited

The Readonly<T[K]> for primitives is unnecessary because those types don't have any mutable structure - you can't edit what is stored inside a number, string or symbol object. (Also you missed out boolean.) For all primitives you can just map to T[K].

You're already making the properties (that hold these values) read-only by wrapping the whole type in Readonly on the first line, so that's enough.

(If you wrap them with Readonly unnecessarily then TS can generate spurious type errors.)

Collapse
 
bwca profile image
Volodymyr Yepishev

Excellent point, thanks Daniel! 😁