DEV Community

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

Posted on β€’ Edited on

10 1 1 1 1

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

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! 😁

11 Tips That Make You a Better Typescript Programmer

typescript

1 Think in {Set}

Type is an everyday concept to programmers, but it’s surprisingly difficult to define it succinctly. I find it helpful to use Set as a conceptual model instead.

#2 Understand declared type and narrowed type

One extremely powerful typescript feature is automatic type narrowing based on control flow. This means a variable has two types associated with it at any specific point of code location: a declaration type and a narrowed type.

#3 Use discriminated union instead of optional fields

...

Read the whole post now!