Improve runtime type guarding capabilities of generated Typescript code
See original GitHub issueIs your feature request related to a problem? Please describe. When generating Typescript code the Bebop compiler composes the fields of a record into a Typescript interface and creates a constant which defines encode and decode methods that accept that interface.
So the following schema:
union SuperHero {
1 -> readonly struct GreenLantern {
float32 powerLevel;
}
2 -> readonly struct Batman {
float32 powerLevel;
}
}
produces the following code:
export declare namespace Bebop.Example {
interface IGreenLantern {
readonly powerLevel: number;
}
const GreenLantern: {
discriminator: 1;
encode(message: IGreenLantern): Uint8Array;
encodeInto(message: IGreenLantern, view: BebopView): number;
decode(buffer: Uint8Array): IGreenLantern;
readFrom(view: BebopView): IGreenLantern;
};
interface IBatman {
readonly powerLevel: number;
}
const Batman: {
discriminator: 2;
encode(message: IBatman): Uint8Array;
encodeInto(message: IBatman, view: BebopView): number;
decode(buffer: Uint8Array): IBatman;
readFrom(view: BebopView): IBatman;
};
type ISuperHero = {
discriminator: 1;
value: IGreenLantern;
} | {
discriminator: 2;
value: IBatman;
};
const SuperHero: {
encode(message: ISuperHero): Uint8Array;
encodeInto(message: ISuperHero, view: BebopView): number;
decode(buffer: Uint8Array): ISuperHero;
readFrom(view: BebopView): ISuperHero;
};
}
The promise of Bebop is to be more type-safe than JSON - and at face value this generated code keeps that promise. Now lets define a function that uses this generated code and step through it:
function getSuperHero(): Bebop.Example.ISuperHero {
if (getRandomNumber() > 5) {
return {
discriminator: Bebop.Example.Batman.discriminator,
value: {
powerLevel: 200
} as Bebop.Example.IBatman
}
}
return {
discriminator: Bebop.Example.GreenLantern.discriminator,
value: {
powerLevel: 200
} as Bebop.Example.IGreenLantern
}
}
One of the first issues that presents itself is how a developer must go about constructing and returning a superhero:
// creating a superhero is very verbose
return {
// while 'discriminator' must be either '1' or '2'
// there is nothing that prevents a developer from setting the wrong discriminator for a value
// this can result in subtle and hard to diagnose runtime exceptions
// especially if a developer fails to use the constant (which they can and will do if given a choice)
discriminator: Bebop.Example.Batman.discriminator,
// the type check if done based on the discriminator
// so even with the 'as` keyword TSC infers the type as whatever matches the `discriminator`
value: {
powerLevel: 200
} as Bebop.Example.IBatman
}
Further, because objects are based on interfaces there is no way to implement type guards or differentiate between types:
const superHero = getSuperHero();
// not possible
if (superHero.value instanceof Bebop.Example.IBatman)
// not possible
if (superHero.value instanceof Bebop.Example.IGreenLantern)
// not possible
function isBatman(superHero: Bebop.Example.ISuperHero) {
return superHero.value instanceof Bebop.Example.Batman;
}
Which means the following is the only way to check the type of a value within a union:
// this isn't ideal
if (superHero.discriminator === 1 || superHero.discriminator === Bebop.Example.Batman.discriminator) {
console.log("I'm Batman")
}
And there is no way to check what the type of an object is at all:
const batman: Bebop.Example.IBatman = {
powerLevel: 200
};
// not valid
if (batman instanceof Bebop.Example.IBatman)
Describe the solution you’d like The Bebop compiler should produce Typescript code that leverages classes. This would allow not only runtime type checking but also allow unions to be used in a much more ergonomic way, like such:
class Superhero {
discriminator: 1 | 2;
value: BaseGreenLantern | BaseBatman;
constructor(value: BaseGreenLantern | BaseBatman) {
this.value = value;
if (value instanceof BaseGreenLantern) {
this.discriminator = 1;
}
}
That is just one example.
Issue Analytics
- State:
- Created 2 years ago
- Reactions:2
- Comments:9 (8 by maintainers)
You can’t use
instanceof
in aswitch
statement in TypeScript, so making this class-y is what would actually force a chain ofif
statements. (You can’t writeswitch (typeof v) {}
, because the result is"object"
for all classes.) We indeed already have excellent type narrowing checks from tsc with just the discriminators inif
orswitch
: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#equality-narrowingTypeScript in VS Code doesn’t seem to support “generate switch cases” by default for either approach, but I found an extension that supports generating cases from
switch (v)
whenv
is a union of constants or an enum.I am starting to think that neither option is clearly more ergonomic or obvious than the other (they have pros and cons). But as @Eadword points out, the system we already have works and is type-safe and fast. I’m not sure if the constructor perf hit is too bad, but I think that
instanceof
can involve icky string comparisons and nested [[HasInstance]] function calls etc that I feel a bit weird about, whereas comparing(value.tag === MyEnum.One)
is definitely super fast at runtime.So, I am now leaning towards keeping things as they are and making them better in the ways @Eadword listed.
I have been talking a bit with @lynn and I think we should keep what we have right now with a few changes:
I*
types to be the same as the const (static implementation) since TS allows this overlap and it makes sense in almost all cases.Example of generated code:
Example of using:
discriminator
to something that takes less keystrokes. Sounds liketag
is winning.Now building on (1) above, this will give the illusion of having classes from a use standpint without having the added weight of classes that thinly wrap the types. IF we use classes, we will probably end up writing something like
which then means to use you have to write
rather than just
with my proposal.
Further you add overhead with classes, and unlike in C#, using
instanceof
in JS is not particularly great most of the time. Example:You will be unable to do an exhastive check on a param of type
MyUnion
because TS does not recgonize all subclasses as options. Instead you will still have to writeIn my opinion it is much nicer to write
which has the added benefit of most IDEs being able to auto fill in branches for you because it knows all the enum variants.
BTW,
assertUnreachable
is a trival function to define that will force a compile-time error if it receives a value that is notnever
which only can happen if you did not exhaustively handle all cases already: