shal
shal

Reputation: 3024

I want totally immutable object in TS

I have some big object, like

const a={
 b:33, 
 c:[78, 99], 
 d:{e:{f:{g:true, h:{boom:'selecta'}}}};/// well, even deeper than this...

And I'd like TS not to allow me to do

a.d.e.f.h.boom='respek';

How can I immutate the object completely? Is it only by creating interface with "readonly" and interfaces for each deeply nested object?

Upvotes: 46

Views: 30633

Answers (6)

Ozymandias
Ozymandias

Reputation: 2759

We now have the option as const which is a syntactically concise way of what @phil294 mentioned as the first option (nested readonly).

const a = {
    b: 33,
    c: [78, 99],
    d:{e:{f:{g:true, h:{boom:'selecta'}}}}
} as const;

a.d.e.f.h.boom = 'respek'; //Cannot assign to 'boom' because it is a read-only property.ts(2540)

As an added bonus, you can make inputs to functions nested immutable using this trick:

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

export type Immutable<T> = {
  readonly [K in keyof T]: T[K] extends Function ? T[K] : ImmutableObject<T[K]>;
}

so this would happen

const a = {
    b: 33,
    c: [78, 99],
    d:{e:{f:{g:true, h:{boom:'selecta'}}}}
}

function mutateImmutable(input: Immutable<typeof a>) {
    input.d.e.f.h.boom = 'respek'; //Cannot assign to 'boom' because it is a read-only property.ts(2540)
}

Upvotes: 47

Nikola Stojiljković
Nikola Stojiljković

Reputation: 126

Other OOP languages have class immutability by default, and TypeScript is no different. You can go with a classic OO way, like in Java or PHP:

export class ClassicImmutableObject {
    private readonly _stringProperty: string;
    private readonly _numberProperty: number;

    constructor(stringProperty: string, numberProperty: number) {
        this._stringProperty = stringProperty;
        this._numberProperty = numberProperty;
    }
    
    get stringProperty(): string {
        return this._stringProperty;
    }

    get numberProperty(): number {
        return this._numberProperty;
    }

    withStringProperty(stringProperty: string): ClassicImmutableObject {
        return new ClassicImmutableObject(stringProperty, this._numberProperty);
    }

    withNumberProperty(numberProperty: number): ClassicImmutableObject {
        return new ClassicImmutableObject(this._stringProperty, numberProperty);
    }
}

You can then use it in your code:

import { ClassicImmutableObject } from './classic-immutable-object';

//...
immutableObjectExperiment(): string {
    const immutableObject: ClassicImmutableObject = 
        new ClassicImmutableObject('test string', 123);

    // this is not allowed: (TS2540 Can not assign to read-only property)
    //immutableObject.stringProperty = 'modified';

    let result = `Original classic immutable object: ${JSON.stringify(immutableObject)} \r\n`;
    result += 'This demonstrates how to use getters:\r\n';
    result += `stringProperty value: ${immutableObject.stringProperty}\r\n`;
    result += `numberProperty value: ${immutableObject.numberProperty}\r\n\r\n`;

    const modifiedImmutableObject: ClassicImmutableObject =
      immutableObject.withStringProperty('modified test string');

    result += `Modified classic immutable object with only stringProperty changed: ${JSON.stringify(modifiedImmutableObject)}\r\n`;
    result += `Original immutable object is still available and unchanged: ${JSON.stringify(immutableObject)}`;

    return result;
}

The result of this function should be:

Original classic immutable object: {"_stringProperty":"test string","_numberProperty":123}
This demonstrates how to use getters:
stringProperty value: test string
numberProperty value: 123

Modified classic immutable object with only stringProperty changed: {"_stringProperty":"modified test string","_numberProperty":123}
Original immutable object is still available and unchanged: {"_stringProperty":"test string","_numberProperty":123}

Upvotes: 1

Martin Jaskulla
Martin Jaskulla

Reputation: 482

Minko Gechev has created DeepReadonly types:

type DeepReadonly<T> =
  T extends (infer R)[] ? DeepReadonlyArray<R> :
  T extends Function ? T :
  T extends object ? DeepReadonlyObject<T> :
  T;

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

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

interface Person {
  name: string;
  job: { company: string, position:string };
}

const person: DeepReadonly<Person> = {
  name: 'Minko',
  job: {
    company: 'Google',
    position: 'Software engineer'
  }
};

person.job.company = 'Alphabet'; // Error

Upvotes: 6

user3048859
user3048859

Reputation: 192

This works:

const a= new class {
    readonly b = 33, 
    readonly c:ReadonlyArray<number> = [78, 99], 
    readonly d = new class {
        readonly e = new class {
            readonly f = new class {
                readonly g:true, 
                readonly h: new class {
                    readonly boom:'selecta'}}}};

Upvotes: 0

phil294
phil294

Reputation: 10822

As described in https://www.typescriptlang.org/docs/handbook/interfaces.html, you can use readonly on class/interface properties or Readonly<...>/ReadonlyArray<> for immutable objects and arrays. In your case, this would look like the following:

const a: Readonly<{
    b: number,
    c: ReadonlyArray<number>,
    d: Readonly<{
        e: Readonly<{
            f: Readonly<{
                g: boolean,
                h: Readonly<{
                    boom: string
                }>
            }>
        }>
    }>
}> = {
        b: 33,
        c: [78, 99],
        d:{e:{f:{g:true, h:{boom:'selecta'}}}}
}

a.d.e.f.h.boom = 'respek'; // error: Cannot assign to 'boom' because it is a constant or a read-only property.

Obviously, this is quite the tautological statement, so I suggest you define proper class structure for your object. You are not really taking advantage of any of Typescript's features by just declaring a nested, untyped object.

But if you really need to go without type definitions, I think the only way is defining a freezer (love the term :D) like Hampus suggested. Taken from deepFreeze(obj) function from MDN:

function freezer(obj) {
    Object.getOwnPropertyNames(obj).forEach(name => {
        if (typeof obj[name] == 'object' && obj[name] !== null)
            freezer(obj[name]);
    });
    return Object.freeze(obj);
}

const a = freezer({
    b:33, 
    c:[78, 99], 
    d:{e:{f:{g:true, h:{boom:'selecta'}}}}});

a.d.e.f.h.boom='respek'; // this does NOT throw an error. it simply does not override the value.

tl;dr: You cannot get compiler type errors without defining types. That is the whole point of Typescript.

edit:

this very last statement is wrong. For example,

let a = 1
a = "hello"

will throw an error because the type is implicitly set to number. For readonly however, I think, you will need proper declaration as defined above.

Upvotes: 9

Hampus
Hampus

Reputation: 2799

Have a look at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze or https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty

Object.freeze might do what you want but at least WebStorm does not warn you when trying to edit the object and Chrome fails silently.

const obj = { val: 1 }; 
Object.freeze(obj);
obj.val = 2;

console.log(obj);
-> { val: 1 }

Nothing can be added to or removed from the properties set of a frozen object. Any attempt to do so will fail, either silently or by throwing a TypeError exception

Upvotes: 0

Related Questions