TypeScript: Support `readonly` type operator to allow correct definition of `Object.freeze`

TypeScript Version: 2.0@RC

Code

// Current definition of `Object.freeze`:
// freeze<T>(o: T): T;

const o = Object.freeze({a: 1});
o.a = 2;  // Should throw type error
console.log(o);  // {a: 1}

Expected behavior: Frozen object has readonly properties Actual behavior: Type system allows me to write to o’s properties

About this issue

  • Original URL
  • State: closed
  • Created 8 years ago
  • Reactions: 53
  • Comments: 46 (17 by maintainers)

Commits related to this issue

Most upvoted comments

@deregtd what about

type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> }

type T = DeepReadonly<{
  a: {
    b: number
  }
}>

const x: T = { a: { b: 1 } }

x.a.b = 2 // error Cannot assign to 'b' because it is a constant or a read-only property.

Would like to add my voice to the request for a deep, recursive readonly. Would be super useful and make immutability very good in TypeScript.

In our huge Typescript app, we’re using a data storage mechanism that keeps complicated class-based types inside data stores. The rest of the app consumes these objects from the stores, but there’s always a risk of developers trying to modify the objects that come out of the stores, which ruins delta checking across the app. We could use object.freeze(), but that plays havoc with JIT.

Instead, we maintain duplicate deep readonly versions of all of our model objects. The stores distribute readonly versions, and if anyone actually needs to update them, they first clone the readonly object back into an impl object, modify it, and then shove it back in the store. The store takes it in and stores it as a readonly version.

The general data access pattern works fantastically. There’s no javascript overhead from object.freeze(), and our engineers are unable to do terrible things with our data models (outside of <any> casting, of course…) The big issue is just that we have a ton of these deep readonly models that we’re manually maintaining, and also having to have comments to maintain which methods are readonly-accessible. We’re used to just using “const” in C++ to solve this problem, both on the accessors (from stores) and for the readonly-safe methods in a class, so this is definitely a step backward, and has introduced some engineer errors by not keeping the two versions in sync.

At least we have readonly at all, it was a huge boon to us to get that. 😃 I’m just hoping for a true deep “const”-style readonly someday.

I can currently use this:

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

Which allows me to set each property as readonly:

type Foo = Freeze<{
  one: string
  two: string
}>
var foo: Foo = {one:"one", two:"two"}
foo.one = "ONE" // error

The issue I have with this is type Foo isn’t named, and I lose other benefits of using an interface over a type def.

I would like to be able to do something like this:

readonly interface Foo {
  one: string
  two: string
}

It could be a deep readonly, but I would be ok with it being shallow. Ultimately I’m trying to avoid doing this:

interface Foo {
  readonly one: string
  readonly two: string
}

If someone adds another property, it may not be clear that Foo is supposed to be treated as an immutable type.

There is no readonly type operator; i.e. freeze<T>(o: T): readonly T;. so there is no way to do this at the time being.

We have discussed adding a readonly type operator that would recursively mark all properties as readonly.

I propose the issue be closed now. Read-only array types have been around for a year, which means the following works as expected. @DanielRosenwasser @RyanCavanaugh

type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };

type Foo = { a: { b: { c: [ { x: 'y' } ] } } };
const foo: Foo = { a: { b: { c: [ { x: 'y' } ] } } };

type ReadonlyFoo = DeepReadonly<Foo>;

const readonlyFoo: ReadonlyFoo = foo;

// All type errors
readonlyFoo.a = readonlyFoo.a;
readonlyFoo.a.b = readonlyFoo.a.b;
readonlyFoo.a.b.c = readonlyFoo.a.b.c;
readonlyFoo.a.b.c[0] = readonlyFoo.a.b.c[0];
readonlyFoo.a.b.c[0].x = readonlyFoo.a.b.c[0].x;

I just want to submit that ideally a solution here could also solve another problem which is one of my biggest gripes with TypeScript: unnecessary type widening. Since readonly fields should not be widened, it seems like literals passed to Object.freeze should not have their types widened:

const o = Object.freeze({ x: 3, y: 'hello' });
// o: { x: number; y: string }
// but would ideally be
// o: { readonly x: 3; readonly y: 'hello' }

In the linked issue I proposed a readonly term operator for this purpose, but it would actually be preferable if it were possible to express a type for Object.freeze that could accomplish this, since then I could give the same type to a function which did nothing but return the original object, eliminating the runtime overhead if all I want is the static check. Thus being able to type Object.freeze such that it inferred the type { readonly x: 3; readonly y: 'hello' } in the above example would be a strictly more powerful feature and would kill two birds with one stone.

Here’s a deep readonly that also makes Arrays deeply readonly: Playground.

Let me know if you find any issues with it. Thanks to @tycho01 for some of the utility types.

@asfernandes neither for tuples. Without mapped conditional types is pretty hard to write conditional logic. This is the best result I got so far (with some bad hacks)

declare global {
  interface Array<T> {
    _type: T
    _kind: 'Array'
  }
  interface Object {
    _kind: 'Object'
  }
}

export type Readonlyable = Array<any> | Object

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

export type OneLevelReadonly<T extends Readonlyable> = {
  Array: ReadonlyArray<Readonly<T['_type']>>,
  Object: { readonly [K in keyof T]: DeepReadonlyObject<T[K]> }
}[T['_kind']]

export type DeepReadonly<T extends { [key: string]: Readonlyable }> = { readonly [K in keyof T]: OneLevelReadonly<T[K]> }

type T = DeepReadonly<{
  a: {
    b: {
      c: number
      // bar: Foo // this leads to Element implicitly has an 'any' type
      // baz: Array<number> // this leads to Element implicitly has an 'any' type
    }
  },
  d: Array<number>,
  e: Array<{ foo: number }>,
  f: Array<{ foo: number, bar: { bax: number } }>, // this doesn't work, bax is not readonly
  g: Array<{ foo: number, bar: Array<number> }> // this doesn't work, bar is not readonly
  // the previuos 2 cases must be handled with another type definition like in h
  h: Array<Foo>,
  i: Foo
}>

type Foo = DeepReadonly<{ foo: number, bar: Array<number> }>

declare var x: T

x.a.b.c = 2 // ok, error
x.d[0] = 2 // ok, error
x.e[0].foo = 2 // ok, error
x.f[0].foo = 2 // ok, error
x.f[0].bar.bax = 2 // NO error
x.g[0].foo = 2 // ok, error
x.g[0].bar[0] = 2 // NO error
x.i.bar = 2 // ok, error

Not sure why function props are singled out in #21316. Perhaps to showcase the usage of NonFunctionPropertyNames<T>? Regardless, unless I’m missing something, this ought to do the trick:

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

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

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

Playground.

Note the union in DeepReadonlyObject<T>, it takes care of both regular functions and hybrid interfaces-functions, like this one:

interface A {
  (): string;
  prop: number;
}

Just in case, I don’t know TS that well, this may have unexpected side effects.

One more go: I think I’ve ironed out a lot of problems. It now works for primitives, for ES6 Map and Set, and for some pretty complex nested structures. Best of all, all you need to do is export the “Const” type and use it, and type T can be assigned to type Const<T> without issue.

I started with some of the ideas from @gcanti and also used the “TupleHasIndex” trick from @tycho01’s typical repo. This code only works for me under Typescript 2.5.2 with target ES6.

// An attempt at implementing const-correctness in Typescript.

declare global {
  interface Array<T> {_kind: 'array', _t1: T, _t2: never}
  interface Map<K,V> {_kind: 'map', _t1: K, _t2: V}
  interface Object {_kind: 'object', _t1: never, _t2: never}
  interface Set<T> {_kind: 'set', _t1: T}
};

export type Const<T extends Array<any> | Map<any,any> | Object | Set<any>> = {
  array: ConstArray<T & {length: number}>,
  map: ReadonlyMap<Readonly<T['_t1']>,Readonly<T['_t2']>>;
  object: ConstObject<T>,
  set: ReadonlySet<Readonly<T['_t1']>>,
}[T['_kind']];

type ConstArray<T extends ArrayLike<any>> = {
  0: ReadonlyArray<Const<T[0]>>,
  1: {
    0: Readonly<[Const<T[0]>]>,
    1: {
      0: Readonly<[Const<T[0]>,Const<T[1]>]>,
      1: {
        0: Readonly<[Const<T[0]>,Const<T[1]>,Const<T[2]>]>,
        1: Readonly<[Const<T[0]>,Const<T[1]>,Const<T[2]>,Const<T[3]>]>,
      }[TupleHasIndex<T,'3'>]
    }[TupleHasIndex<T,'2'>]
  }[TupleHasIndex<T,'1'>]
}[TupleHasIndex<T,'0'>];

type ConstObject<T extends {[key: string]: any}> =
  {readonly [K in keyof T]: Const<T[K]>};

type TupleHasIndex<A, I extends string> =
  ({[K in keyof A]: '1'} & {[key: string]: '0'})[I];

// Example usages. Note that the only interface you need is Const.
// You can check that things are working correctly by using the Typescript server's
// GetType call on these values. You'll see that they reduce to simple types with
// "readonly" and Readonly[Array|Map|Set] where appropriate.
declare const a: Const<number>;
declare const b: Const<string>;
declare const c: Const<boolean>;
declare const d: Const<Map<number,{a: boolean, b: string}>>;
declare const e: Const<Set<string>>;
declare const x: Const<number[]>;
declare const y: Const<[number, string, boolean, {a: number, b: string}]>;
declare const z: Const<{a: number[], b: {c: string, d: string[]}[]}>;

type MyType = [number[], {a: string, b: boolean}];
const value: MyType = [[1, 2, 3], {a: 'test', b: true}];
const f: Const<MyType> = value;

I’ll wrap this up in a library and make it available.

Interesting. Will have to play with that concept. That still doesn’t get us there (can’t have immutable methods and mutable methods, so anything with methods is hosed), but it might help some of our simpler method-less models that we currently have copies of (RO and RW). Thanks!

One note: DeepReadOnly implementation in https://github.com/Microsoft/TypeScript/pull/21316 removes all function properties from provided object, but Object.freeze doesn’t.

@AlexGalays: the following we can’t preserve yet, limitations not of his type but of our set of operators we can use today:

  • tuples
  • string indices
  • symbols

Note that at #12424 they’re similarly trying to lift a key based operator to become ‘deep’, in their case partial/? rather than readonly.

Very nice, @gcanti! I think I was able to extend your example to be closer to working!

declare global {
  interface Array<T> {
    _type: T
    _kind: 'Array'
  }
  interface Object {
    _kind: 'Object'
  }
}

export type Readonlyable = Array<any> | Object

export type OneLevelReadonly<T extends Readonlyable> = {
  Array: ReadonlyArray<DeepReadonly<T['_type']>>,
  // Here's the main difference between my version and yours -
  // in your version, you iterated over the keys of the object here,
  // but I delegate that back to DeepReadonly. That's what
  // makes array values of objects work.
  Object: DeepReadonly<T>,
}[T['_kind']]

export type DeepReadonly<T extends { [key: string]: Readonlyable }> = { readonly [K in keyof T]: OneLevelReadonly<T[K]> }

type T = DeepReadonly<{
  a: {
    b: {
      c: number,
      bar: Foo,
      baz: Array<number>,
    }
    foo: Foo,
  },
  d: Array<number>,
  e: Array<{ foo: number }>,
  f: Array<{ foo: number, bar: { bax: number } }>,
  g: Array<{ foo: number, bar: Array<number> }>,
  h: Array<Foo>,
  i: Foo
}>

type Foo = { foo: number, bar: Array<number> };

declare var x: T
// All have type number!
const a = x.a.b.c;
const b = x.a.b.bar.bar[0];
const c = x.a.b.baz[0];
const d = x.a.foo.foo;
const e = x.a.foo.bar[0];

x.a.b.c = 2 // ok, error
x.d[0] = 2 // ok, error
x.e[0].foo = 2 // ok, error
x.f[0].foo = 2 // ok, error
x.f[0].bar.bax = 2 // ok, error
x.g[0].foo = 2 // ok, error
x.g[0].bar[0] = 2 // ok, error
x.i.bar = 2 // ok, error

Note that things will still break if I make Foo a DeepReadonly object. However, with this method, we can at least use DeepReadonly as a replacement for C++ style “const” on functions, since that use case only ever depends on one level of DeepReadonly being available. That’s the main way I wanted to use it, so I’m very pleased. Nice trick with the “_type” / “_kind” way to go from Array<T> -> T!

@gcanti this does not work correctly for embedded arrays.