TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Suggestion: DeepReadonly<T> type

Open mprobst opened this issue 8 years ago • 54 comments
trafficstars

TypeScript Version: 2.1.1 / nightly (2.2.0-dev.201xxxxx)

Code

It would be nice to have a shard, standard library type that allows to express deep readonly-ness (not really const, since methods are out of scope, but still...):

interface Y { a: number; }
interface X { y: Y; }
let x: Readonly<X> = {y: {a: 1}};
x.y.a = 2;  // Succeeds, which is expected, but it'd be nice to have a common way to express deep readonly

type DeepReadonly<T> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>;
}
let deepX: DeepReadonly<X> = {y: {a: 1}};
deepX.y.a = 2; // Fails as expected!

mprobst avatar Feb 07 '17 09:02 mprobst

The same for Partial

felixfbecker avatar Feb 07 '17 12:02 felixfbecker

Having a DeepReadonly<T> type would probably also allow for const methods (similar to how C++ does this).

class A {
  public x: number;
  unsafe() { // `this` is of type "A"
    this.x = 2;
  }
  const safe() { // "const" causes `this` to be of type "DeepReadonly<A>"
    console.log(this.x);
    // this.x = …; would yield a compiler error here
  }
}

let a: A;
a.unsafe(); // works fine, because "a" is of type "A"
a.safe(); // works fine, because "A" is a superset of "DeepReadonly<A>"

let readonlyA: DeepReadonly<A>;
a.safe(); // works fine, because "a" is of type "DeepReadonly<A>"
a.unsafe(); // would result in an error, because "DeepReadonly<A>" is not assignable to the required `this` type ("A")

iRath96 avatar Mar 04 '17 00:03 iRath96

This has somewhat odd behaviour for callables, e.g. when calling set on a DeepReadonly<Map<...>>:

Cannot invoke an expression whose type lacks a call signature. Type 'DeepReadonly<(key: string, value?: number | undefined) => Map<string, number>>' has no compatible call signatures.

mprobst avatar Jul 20 '17 09:07 mprobst

@mprobst I just ran into this issue using the same type... Any idea how to fix this?

ChuckJonas avatar Sep 18 '17 04:09 ChuckJonas

There are a few complications for the proposal in the OP, first as you noted the compiler does not know that array.psuh is a mutating function and should not be allowed, (today we work around that by having ReadOnlyArray and ReadonlyMap); second, the mapped type creates a new type, and for a recursive type comparison can be expensive, since we are not using the compiler type identity checks, resulting in worse performance. We did contamplate adding it in the library when we added Readonly and Partial and then decided against that.

https://github.com/Microsoft/TypeScript/issues/10725 would seem a better solution here.

mhegazy avatar Sep 18 '17 20:09 mhegazy

This will be possible in typescript 2.8 thanks to mapped types:

export type primitive = string | number | boolean | undefined | null
export type DeepReadonly<T> = T extends primitive ? T : DeepReadonlyObject<T>
export type DeepReadonlyObject<T> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>
}

declare const shallowReadOnly: Readonly<{ a: { b: number } }>
shallowReadOnly.a.b = 2 // Ok 😞

declare const readOnly: DeepReadonly<{ a: { b: number } }>
readOnly.a.b = 2 // Error 🎉

Dean177 avatar Feb 26 '18 23:02 Dean177

Does it work for Arrays?

esamattis avatar Mar 12 '18 08:03 esamattis

With a small modification it does:

export type primitive = string | number | boolean | undefined | null
export type DeepReadonly<T> =
  T extends primitive ? T :
  T extends Array<infer U> ? DeepReadonlyArray<U> :
  DeepReadonlyObject<T>

export interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {}

export type DeepReadonlyObject<T> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>
}

const foo: DeepReadonly<Array<number>> = [1, 2, 3]
foo[3] = 8 // Index signiture in type 'ReadonlyArray<number>' only permits reading

(ReadonlyArray is already a thing: https://www.typescriptlang.org/docs/handbook/interfaces.html#readonly-properties)

EDIT: Thanks @cspotcode & @mkulke

Dean177 avatar Mar 12 '18 10:03 Dean177

@Dean177: It doesn't make the elements of the array deeply readonly, correct? That seems like a big limitation. I tried to implement it myself and couldn't. I got errors about DeepReadonly circularly referencing itself. Seems like the numeric index signature causes problems.

cspotcode avatar Mar 16 '18 19:03 cspotcode

@cspotcode would this work?

type NonFunctionPropertyNames<T> = {
  [K in keyof T]: T[K] extends Function ? never : K
}[keyof T];

type DeepReadonlyObject<T> = {
    readonly [P in NonFunctionPropertyNames<T>]: DeepReadonly<T[P]>;
};

interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {}

type DeepReadonly<T> =
  T extends any[] ? DeepReadonlyArray<T[number]> :
  T extends object ? DeepReadonlyObject<T> :
  T;

interface Step {
  length: number;
}

interface Trip {
  mode: 'TRANSIT' | 'CAR';
  steps: Step[];
}

type Trips = Trip[];

function mgns(trips: DeepReadonly<Trips>): void {
  const trip = trips[0];
  if (trip === undefined) {
    return;
  }
  trips.pop(); // readonly error
  trip.mode = 'WALK'; // readonly error
  trip.steps.push({ length: 1 }); // readonly error
  const step = trip.steps[0];
  if (step === undefined) {
    return;
  }
  step.length = 2; // readonly error
}

mkulke avatar Mar 16 '18 20:03 mkulke

Yeah, that mostly does the trick, thanks! But why are you stripping methods off of the readonly objects? Is it because methods might trigger mutations?

The only change I would make is replacing T extends any[] with T extends ReadonlyArray<any>.

EDIT: @mkulke another thought: Function properties might also be objects with nested properties. Maybe it's better to map them as DeepReadonlyObject. This will preserve the nested properties and strip off the invocation signatures.

On Fri, Mar 16, 2018 at 4:32 PM, Magnus Kulke [email protected] wrote:

@cspotcode https://github.com/cspotcode would this work?

type NonFunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T];

type DeepReadonlyObject<T> = { readonly [P in NonFunctionPropertyNames<T>]: DeepReadonly<T[P]>; };

interface DeepReadonlyArray<T> extends ReadonlyArray<DeepReadonly<T>> {}

type DeepReadonly<T> = T extends any[] ? DeepReadonlyArray<T[number]> : T extends object ? DeepReadonlyObject<T> : T;

interface Step { length: number; }

interface Trip { mode: 'TRANSIT' | 'CAR'; steps: Step[]; }

type Trips = Trip[];

function mgns(trips: DeepReadonly<Trips>): void { const trip = trips[0]; if (trip === undefined) { return; } trips.pop(); // readonly error trip.mode = 'WALK'; // readonly error trip.steps.push({ length: 1 }); // readonly error const step = trip.steps[0]; if (step === undefined) { return; } step.length = 2; // readonly error }

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/Microsoft/TypeScript/issues/13923#issuecomment-373836516, or mute the thread https://github.com/notifications/unsubscribe-auth/AAW-uCsk2KXVLWzWM_i8s0vgq4uKOeGYks5tfCFagaJpZM4L5Rm3 .

cspotcode avatar Mar 17 '18 20:03 cspotcode

Thank you all for your suggestions. I used them to come up with this:

export type DeepPartial<T> =
	T extends Array<infer U> ? DeepPartialArray<U> :
	T extends object ? DeepPartialObject<T> :
	T;

export type DeepPartialNoMethods<T> =
	T extends Array<infer U> ? DeepPartialArrayNoMethods<U> :
	T extends object ? DeepPartialObjectNoMethods<T> :
	T;

export interface DeepPartialArrayNoMethods<T> extends Array<DeepPartialNoMethods<T>> {}
export interface DeepPartialArray<T> extends Array<DeepPartial<T>> {}

export type DeepPartialObject<T> = {
	[P in keyof T]?: DeepPartial<T[P]>;
};

export type NonFunctionPropertyNames<T> = {
	[P in keyof T]: T[P] extends Function ? never : P;
}[keyof T];

export type DeepPartialObjectNoMethods<T> = {
	[P in NonFunctionPropertyNames<T>]?: DeepPartialNoMethods<T[P]>;
};

I personally use it like this:

class MyType {
  constructor(init?: DeepPartialNoMethods<MyType>) {
    if (init) {
      Object.assign(this, init);
    }
  }
}

EDIT: oops, forgot to do array check before object check rather than after.

RomkeVdMeulen avatar Mar 29 '18 09:03 RomkeVdMeulen

This package's @types has been working well for me.

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/deep-freeze/index.d.ts

g-harel avatar Jun 12 '18 15:06 g-harel

This is my implementation of DeepReadonly. I named it Immutable so it doesn't clash with Readonly.

type Primitive = undefined | null | boolean | string | number | Function

type Immutable<T> =
  T extends Primitive ? T :
    T extends Array<infer U> ? ReadonlyArray<U> :
      T extends Map<infer K, infer V> ? ReadonlyMap<K, V> : Readonly<T>

type DeepImmutable<T> =
  T extends Primitive ? T :
    T extends Array<infer U> ? DeepImmutableArray<U> :
      T extends Map<infer K, infer V> ? DeepImmutableMap<K, V> : DeepImmutableObject<T>

interface DeepImmutableArray<T> extends ReadonlyArray<DeepImmutable<T>> {}
interface DeepImmutableMap<K, V> extends ReadonlyMap<DeepImmutable<K>, DeepImmutable<V>> {}
type DeepImmutableObject<T> = {
  readonly [K in keyof T]: DeepImmutable<T[K]>
}

It handles ReadonlyArray and ReadonlyMap. It also handles Function types so their instances still can be called after being applied by this modifier.

nieltg avatar Jul 06 '18 01:07 nieltg

Is there anything else needed from the type system side to adequately address the use cases here?

RyanCavanaugh avatar Aug 15 '18 18:08 RyanCavanaugh

Is there anything else needed from the type system side to adequately address the use cases here?

@RyanCavanaugh: There is no way to mark tuple types as readonly in the language right now.

simast avatar Oct 28 '18 06:10 simast

I thought you could do that with tuple mapping in 3.1

nickserv avatar Oct 28 '18 06:10 nickserv

I thought you could do that with tuple mapping in 3.1

I don't believe this applies to actual tuple values, just mapped object types that have tuples as properties. Here is an example of a tuple in an object I am referring to:

const test: {
    readonly tuple: [number, string]
} = {
    tuple: [1, "dsffsd"]
}

test.tuple[0] = 2 // Works (but should be somehow marked as readonly)

simast avatar Oct 28 '18 06:10 simast

I'm accidentally mutating some constant data object. I put Readonly<> everywhere, but the compiler didn't catch anything, precisely because the bug is mutating deep in the object...

So that would be much needed!

Offirmo avatar Nov 30 '18 03:11 Offirmo

For those interested, DeepReadonly with all edge cases covered is part of ts-essentials package.

krzkaczor avatar Dec 15 '18 23:12 krzkaczor

A slightly more complete DeepReadonly type https://gist.github.com/masterkidan/7322752f569b1bba53e0426266768623

masterkidan avatar Jan 24 '19 20:01 masterkidan

I think the as const PR covers this too? https://github.com/Microsoft/TypeScript/pull/29510

esamattis avatar Jan 25 '19 10:01 esamattis

@epeli : Not quite... The DeepReadonly type can also be applied to classes as well... Lets say we have a class like so

class Foo {
  public bar: number

  public isBar(): boolean {
     return bar == 42
  }
}

type ReadonlyFoo = DeepReadonly<Foo>;

class FooFactory {
    static FooBar () : ReadonlyFoo { 
       const a = new Foo();
      a.bar = 42;
      return a as ReadonlyFoo;
    }

The above will end up creating a readonly of the Foo class, I think as const can only be applied to objects, literals etc ... not to prototypes like the one described above.

masterkidan avatar Jan 26 '19 02:01 masterkidan

Is @epeli correct? Does #29510 effectively cover this? @masterkidan, I guess you mean

   a.bar = 42 

But it seems you should be able to do something like this:

   return a as const

a is not a class but an object. Why do you assume you can't cast it as such?

carpben avatar Feb 03 '19 14:02 carpben

Actually no I'm not :) I misunderstood it.

The as const is for literal types (object literals etc.) only and cannot be used for converting existing types.

esamattis avatar Feb 03 '19 17:02 esamattis

@carpben : Oops, thanks for catching that, I updated my earlier comment.

My point is , the type 'a' that is returned will no longer have the isBar() method.... as the as const is only for objects. Whereas with Readonly, we can expose the functions out as well.

The idea here is to expose out the properties of an object as readonly but also have some controlled methods that will update those properties/encapsulate some business logic needed for updating those properties.

masterkidan avatar Feb 07 '19 00:02 masterkidan

There are a few complications for the proposal in the OP, first as you noted the compiler does not know that array.psuh is a mutating function and should not be allowed,

Yes, it seems that this is getting close to C++ const (which IMO is one of the very best features of C++ and surprisingly uncommon elsewhere), where array.push is known to be a mutating function because it is not marked const in its declaration.

pauldraper avatar Mar 02 '19 16:03 pauldraper

@nieltg I updated yours to handle unknown

type Primitive = undefined | null | boolean | string | number | Function

export type DeepImmutable<T> =
	T extends Primitive ? T :
		T extends Array<infer U> ? DeepImmutableArray<U> :
			T extends Map<infer K, infer V> ? DeepImmutableMap<K, V> :
				T extends object ? DeepImmutableObject<T> : unknown

interface DeepImmutableArray<T> extends ReadonlyArray<DeepImmutable<T>> {}
interface DeepImmutableMap<K, V> extends ReadonlyMap<DeepImmutable<K>, DeepImmutable<V>> {}
type DeepImmutableObject<T> = {
	readonly [K in keyof T]: DeepImmutable<T[K]>
}

paps avatar Apr 08 '19 14:04 paps

@paps @nieltg , my experience using a similar version of DeepReadonly<T> is that it doesn't handle well ReadonlyArray<T>, (and therefor, any type which originally has a sub array, and has been mapped by DeepReadonly<T>).

In Typescript an Array extends a ReadonlyArray, but not the other way around (as a ReadonlyArray type doesn't have certain methods such as push). I recommend changing T extends Array<infer U> ? to T extends ReadonlyArray<infer U>?

carpben avatar Apr 08 '19 15:04 carpben

@paps @nieltg , my experience using a similar version of DeepReadonly<T> is that it doesn't handle well ReadonlyArray<T>, (and therefor, any type which originally has a sub array, and has been mapped by DeepReadonly<T>).

In Typescript an Array extends a ReadonlyArray, but not the other way around (as a ReadonlyArray type doesn't have certain methods such as push). I recommend changing T extends Array<infer U> ? to T extends ReadonlyArray<infer U>?

I think that TypeScript 3.4 is now correctly mapping readonly Array to ReadonlyArray. See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html

vidal7 avatar Apr 08 '19 16:04 vidal7