Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript - how do I manipulate immutable classes?

Tags:

typescript

I want to use immutable classes instead of immutable interfaces, for two reasons:

  • so that I can bundle some code with them (like the doStuff method)
  • to disallow "extraneous properties" (see example below: anywhere it's valid to set otherThing, I want that to cause a compile error)

This is the closest I've been able to come up with that mostly does what I want:

interface IData4 {
  readonly thing1: string;
  readonly thing2: string;
}

class Data4 implements IData4 {
  readonly thing1: string;
  readonly thing2: string;

  constructor(that: IData4, props?: Partial<IData4>){
    Object.assign(this, that);
    if( props ){
      Object.assign(this, props);
    }
  }

  doStuff(){
    return this.thing1 == this.thing2;
  }
}

// more verbose than a normal ctor parameter list, but I like it better anyway
// more readable, and it makes transposition errors less likely when all
// the params are the same type
// I write code to create instances rarely, reading and updating is more frequent
let data4 = new Data4({thing1: "t1", thing2: "t2"});

// GOOD!: error because of "otherThing"
// let other = new Data4({thing1: "t1", thing2: "t2", otherThing: 'blah'});

// GOOD!: error because missing "thing2"
// let other = new Data4({thing1: "t1"});

// this is the usual update case
let data4a = new Data4(data4, {thing2: "t2a"});
log.debug("data4a: " + JSON.stringify(data4a));

// GOOD!  error because of "otherThing"
// let other = new Data4(data4, {thing2: "t2b", otherThing: 'blah'});

// BAD!  want "otherThing" to cause error
// but I'm unlikely to use this construct, I wouldn't specify the spread 
// operator again because I already specified the thing to copy from
let bad2b = new Data4(data4, {...data4, thing2: "t2b", otherThing: 'blah'});

// BAD! want "otherThing" to cause error 
// easy to do accidentally because I'm used to using interfaces 
let bad2c = new Data4({...data4, thing2: "t2b", otherThing: 'blah'});

let iData: IData4 = {thing1: 't1', thing2: 't2'};
// BAD! want an error about "otherThing" 
let iData2 = {...iData, thing2: 't2a', otherThing: 'blah'};

// GOOD! error because of "otherThing"
// let other: IData4 = {thing1: 't1', thing2: 't2', otherThing: 'blah'};

// GOOD! error because lack of "thing2"
// let other: IData4 = {thing1: 't1'};

Problems with this construct:

  • the whole Interface + Class construct is unwieldy and duplicating the property definitions is silly (plus error prone)
  • it still allows me to specify extraneous properties if I use the "spread" operator

So, is there a better way to do this?

  • Is there some way to package up functions with interfaces like I want?
  • Or is there a better type than "Partial" that I should be using?
    • Is there some way to achieve roughly the same functionality as Partial but without allowing extraneous properties to be set?
  • Or is there some way in Typescript where I can use normal constructors with parameter lists to make a new object with a changed property, but not have to specify every property?
    • Specifically, I don't want to be doing: let data4a = new Data4(data4.thing1, "t2a"});
like image 635
Shorn Avatar asked Mar 31 '26 00:03

Shorn


1 Answers

It's not great, but I did sort of find an answer to the question "is there some way to package up functions with interfaces"?

You can use "declaration merging" to make something that approximates an interface with functions:

interface Data7{
  readonly thing1: string;
  readonly thing2: string;
}

namespace Data7{
  export function areThingsEqualLength(value: Data7): boolean {
    return value.thing1.length == value.thing2.length;
  }
}

let data7: Data7 = {thing1: "t1", thing2: "t2"};
let data7a = {...data7, thing2: "t2a"};
log.debug("data7a: " + JSON.stringify(data7a));

log.debug("same length thing?: " + Data7.areThingsEqualLength(data7));

// GOOD!
// error: "'otherThing' does not exist in type"
// let other7a: Data7 = {thing1: "t1", thing2: "t2a", otherThing: 'blah'};

// GOOD!
// error: "Property 'thing1' is missing in type '{ thing2: string; }'."
// let other7b: Data7 = {thing2: 't2b'};

// BAD!
// want it to fail on 'otherThing' 
let data7b = {...data7, otherThing: 'blah'};

// VERY BAD!
// allows creation of an invalid object.
// The function call will explode, even though it's written correctly
let badData7 = {...data7, thing2: undefined};
// log.debug("same length thing?: " + Data7.areThingsEqualLength(badData7));

You can use an import to turn Data7.areThingsEqualLength(data7) into areThingsEqualLength(data7) if preferred.

But I've also realised that I might want to avoid using interfaces: with their accompanying spread operator, they allow construction of invalid objects (see the "VERY BAD!" comment).

So the title question remains (maybe this shouldn't be an answer, maybe it should be an edit to the question, but then it'd be really long).

like image 165
Shorn Avatar answered Apr 02 '26 13:04

Shorn



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!