dingus
dingus

Reputation: 1001

Building a re-serializable wrapper class in TypeScript

I'm working on a utility library for use with a complex API model. We receive JSON-parsed objects from the wire with some kind of guarantee about their structure, as follows:

// Known structure of the message:
interface InputBox {
  top: number;
  height: number;
  left: number;
  width: number;
}
interface InputObj {
  box: InputBox
}

// User code (outside our scope) does something like:
const inputObj: InputObj = JSON.parse(
  '{ "box": { "top": 0, "height": 10, "left": 1, "width": 2, "color": "red" } }'
);

My goal is to create some view of the object which:

For example, user code might look like:

// In-place or new obj constructor would both be fine:
const easyObj = myCoolLibrary(inputObj);

easyObj.box.top = 5;
console.log(easyObj.box.getBottom());  // '15'

JSON.stringify(easyObj);
// -> { "box": { "top": 5, "height": 10, "left": 1, "width": 2, "color": "red" } }
// (Note box.color still present, although missing from the input interface)

After reading a little around options, it seems like:

The thing is, even if these methods are viable, I can't see how they might be implemented in a way that keeps TypeScript happy?

For e.g:

class MyCoolBox implements InputBox {
  constructor(box: InputBox) {
    Object.assign(this, box);
  }

  getBottom() {
    return this.top + this.height;
  }
}
// > Class 'MyCoolBox' incorrectly implements interface 'InputBox'.
// (and doesn't recognise presence of .top, .height)

Object.setPrototypeOf(inputObj.box, MyCoolBox);
inputObj.box.getBottom();
// > Property 'getBottom' does not exist on type 'InputBox'
// Doesn't recognise the change of interface.

Am I missing some sensible way to do this that TypeScript could understand? It seems a reasonable ask to just decorate a JSON-parsed object (of known interface) with some methods!

Upvotes: 2

Views: 507

Answers (1)

0Valt
0Valt

Reputation: 10345

Firstly, it seems like you have a misunderstanding about how implements works. The properties are recognized, it's just that you told the compiler your class implements those properties, but never actually implemented them. Yes, you do the Object.assign in the constructor which achieves the desired result at runtime, but the compiler does not know that.

Since you obviously do not want to spell out every possible property of the InputBox on the class, the solution is to instead use the fact that classes have interfaces of the same name bound to them. Thus, you can make the MyCoolBox interface be a subtype of the interface you wish to extend (i.e. the InputBox):

interface MyCoolBox extends InputBox { }

class MyCoolBox {
  constructor(box: InputBox) {
    Object.assign(this, box);
  }

  getBottom() {
    return this.top + this.height;
  }
}

Secondly, you expect setPrototypeOf to behave like a type guard of sorts, whereas it is simply defined as:

ObjectConstructor.setPrototypeOf(o: any, proto: object | null): any

meaning that by changing the prototype you change nothing about what the compiler knows about the shape of the box object: for it, it's still an InputBox. There is also a problem that classes in JavaScript are mostly a syntactic sugar for functions + prototype-based inheritance.

Setting of the box prototype to MyCoolBox will fail at runtime when you try to call the getBottom method because it's not static, and you only set the static side of the class this way. What you actually want is to set the prototype of MyCoolBox - this will set the instance properties:

const myCoolLibrary = <T extends InputObj>(input: T) => {
  Object.setPrototypeOf(input.box, MyCoolBox.prototype); //note the prototype
  return input as T & { box: MyCoolBox };
};

const enchanced = myCoolLibrary(inputObj);
const bottom = enchanced.box.getBottom(); //OK
console.log(bottom); //10

Finally, as per the example above, you need to tell the compiler that the output type is of the enhanced type. You can do this with a simple type assertion (as T & { box: MyCoolBox }) like above or by letting the compiler infer the enhanced type for you:

{
  const myCoolLibrary = (input: InputObj) => {
    return {
      ...input,
      box: new MyCoolBox( input.box )
    }
  };

  const enchanced = myCoolLibrary(inputObj);
  const bottom = enchanced.box.getBottom(); //OK
  console.log(bottom); //10
}

Playground

Upvotes: 1

Related Questions