I have some JavaScript objects which supply many operations, some of which are not valid depending on the runtime (yet constant) state of the object. I wish to use TypeScript to restrict the set of operations available on these objects, so that invalid ones cannot be called.
I've been able to achieve that by defining a discriminated union and asserting it as the type of my objects.
This approach works, however it has an issue: I don't know how to use TypeScript to make sure that my objects and the discriminated union are compatible, since I'm forcing the type through an assertion.
To further complicate things, I'd like to use this approach to type certain kinds of objects without looking at their state, since they rely on laziness.
Here is a very minimal, silly example to show what I'm trying to do. You can interactively play with this (there are small unimportant differences) here: https://tsplay.dev/Wk18pN
I have a class MyNum which wraps a number. Some of its operations are available only for some kinds of numbers. For instance, round() can only be called on non-integers, while incr() can only be called on integers:
class MyNum {
n: number;
get isInt() { return this.n%1 === 0; }
constructor(n: number) { this.n = n; }
incr() {
if(!this.isInt) throw new Error(`incr() can only be called on integers`);
return this.n+1;
}
round() {
if(this.isInt) throw new Error(`round() can only be called on integers`);
return Math.round(this.n);
}
}
I wish to use TypeScript to statically make sure that incompatible operations cannot be called. I want to get a static error if I try to do the following:
function f(n: MyNum) {
// the following line should fail at compile time,
// since it can fail at runtime, if `!n.isInt`
n.incr();
}
I was able to achieve what I want using a discriminated union, where some operations are available only for some subtypes. Then I just have to cast MyNums to such union:
type NumBase = {
n: number;
isInt: boolean;
}
interface Int extends NumBase {
isInt: true; // discriminant
incr(): number;
}
interface Real extends NumBase {
isInt: false; // discriminant
round(): number;
}
type Num = Int | Real;
// with this I can create a `Num` (by casting a `MyNum`)
function myNum(n: number): Num {
return new MyNum(n) as unknown as Num;
}
And this allows me to write the following type-safe code:
// this fails statically (as expected)
function f(n: Num) {
n.incr(); // Error: Property 'incr' does not exist on type 'Num'
}
// this works (as expected)
function g(n: Num) {
if(n.isInt) {
n.incr() // OK, since `n` is `Int` in this branch
} else {
n.round() // OK, since `n` is `Real` in this branch
}
}
The problem is that TypeScript doesn't make sure that Num and MyNum are compatible. If I have a typo in one of the fields of Num I would get no error. For instance if I had written...
interface Int extends NumBase {
isInt: true; // discriminant
inxr(): Int; // notice the typo: `inxr` vs `incr`
}
...TypeScript would still accept the program. It doesn't realize that MyNum has no inxr field.
I'd like to use this approach for lazy objects too. That means that I'm not allowed to look at the objects' state in order to type them, since that would force strictness:
class LazyNum implements NumBase {
protected readonly compute: () => number;
protected _n: number | undefined;
get n() {
if( this._n === undefined ) this._n = this.compute();
return this._n;
}
get isInt() { return this.n%1 === 0; }
constructor(compute: () => number) {
this.compute = compute;
}
incr() { return this.n+1; }
round() { return Math.round(this.n); }
}
function lazyNum(compute: ()=>number): Num {
return new LazyNum(compute) as unknown as Num;
}
Again, this still works fine, but it has the same problems above. It might just make it a bit harder to define lazyNum in a type safe way.
How can I restrict the set of operations available on a JavaScript object using TypeScript in a type safe way?
You can't really do this easily, especially with isInt being a getter. If you allow isInt to be a method, you can get the behavior you're talking about, but it's pretty messy. Like this:
class MyNum {
n: number;
isInt(): this is { __isInt: true } {
return this.n % 1 === 0;
}
constructor(n: number) { this.n = n; }
incr(this: { __isInt: true } & MyNum) {
if (!this.isInt()) throw new Error(`incr() can only be called on integers`);
return this.n + 1;
}
round(this: this extends { __isInt: true } ? never : MyNum) {
if (this.isInt()) throw new Error(`round() can only be called on integers`);
return Math.round(this.n);
}
}
const n = new MyNum(123);
if (n.isInt()) {
n.incr(); // okay
n.round(); // error
} else {
n.incr(); // error
n.round(); // okay
}
The idea is that isInt() is a custom type guard method, that pretends that isInt() returning true adds a phantom __isInt property of type true to the instance.
This can't work when isInt() is a getter, since getters can't return type predicates. There's an open feature request at microsoft/TypeScript#43368 to support that, but for now it's not possible.
Anyway, the incr and round methods use a this parameter to check whether there's an __isInt property of type true in the instance. If so, then incr() is allowed (because this is assignable to {__isInt: true}) and round() is disallowed (because this is not assignable to the conditional type this extends { __isInt: true } ? never : MyNum). If not, then the reverse happens.
The type for round() is more complicated because if isInt() returns false then MyNum is not narrowed, and round() needs to check for the non-narrowed case. The conditional type basically performs the equivalent of an Exclude, so that if this has been narrowed, you get never (which fails), otherwise you get MyNum (which succeeds).
This works, but I don't know that I'd recommend such an approach. I'd only consider it if you couldn't refactor your code away from a single class with disallowed behaviors based on state... but if you can't refactor then isInt() probably can't be changed from a getter to a method. But it's at least close to what you're asking for.
Playground link to code
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With