Reputation: 1001
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:
Object
s... Still needs polyfill on IE, but maybe is a bit more standard?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
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 class
es 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
}
Upvotes: 1