question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Improve runtime type guarding capabilities of generated Typescript code

See original GitHub issue

Is 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:closed
  • Created 2 years ago
  • Reactions:2
  • Comments:9 (8 by maintainers)

github_iconTop GitHub Comments

1reaction
lynncommented, Mar 21, 2022

Also type narrowing would allow for the IDE to create an exhaustive switch on the users behalf; you wouldn’t need a chain of if statements

You can’t use instanceof in a switch statement in TypeScript, so making this class-y is what would actually force a chain of if statements. (You can’t write switch (typeof v) {}, because the result is "object" for all classes.) We indeed already have excellent type narrowing checks from tsc with just the discriminators in if or switch: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#equality-narrowing

TypeScript 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) when v 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.

1reaction
mattiecnvrcommented, Mar 17, 2022

I have been talking a bit with @lynn and I think we should keep what we have right now with a few changes:

  1. Rename the 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:

export interface MyRecord {
  a: string
  b: number
}

export const MyRecord = {
  serialize(data: MyRecord): Uint8Array {...}
  ...
} as const;

Example of using:

import {MyRecord} from './generated'

const a = 'hello'
const b = 5
const r: MyRecord = {a, b}
MyRecord.serialize(r)
  1. Rename discriminator to something that takes less keystrokes. Sounds like tag is winning.
  2. Generate an enum of the discriminator values for unions. This will make exhaustive checking easier, and more importantly, when you want to go over each option, you won’t have to import each type implementation you are checking for.
  3. Use the enum instead of hardcoding them as numbers

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

export interface IMyRecord {
  a: string
  b: number
}

class MyRecord implements IMyRecord {
  a: string
  b: number
  
  constructor(raw: IMyRecord) {
    this.a = a
    this.b = b
  }
  
  static deserialize(buffer: Uint8Array): MyRecord {...}
  serialize(): Uint8Array {...}
  ...
}

which then means to use you have to write

const buf = new MyRecord({a, b}).serialize()

rather than just

const buf = MyRecord.serialize({a, b})

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:

abstract class MyUnion { ... }

class MyUnionOptA extends MyUnion {
  readonly tag = 1 as const
  ...
}

class MyUnionOptB extends MyUnion {
  readonly tag = 2 as const
  ...
}

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 write

function doThing(v: MyUnionOptA | MyUnionOptB) {
  if (v instanceof MyUnionOptA) {...}
  else if (v instanceof MyUnionOptB) {...}
  else { assertUnreachable(v) }
}

In my opinion it is much nicer to write

...
// generated
type MyUnion = { tag: MyUnionTag.OptA; value: OptA } | { tag: MyUnionTag.OptB; value: OptB };
...
// user written
function doThing(v: MyUnion) {
  switch (v.tag) {
    case MyUnionTag.OptA:
      ...
      break;
    case MyUnionTag.OptB:
      ...
      break;
    default:
      assertUnreachable(v)
  }
}

// OR also valid
function doThing2(v: MyUnion) {
  if (v.tag == MyUnionTag.OptA) { ... }
  else if (v.tag == MyUnionTag.OptB) { ... }
  else assertUnreachable(v)
}

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 not never which only can happen if you did not exhaustively handle all cases already:

function assertUnreachable(v: never): never { throw new Error("This code should never run; check your type definitions!") }
Read more comments on GitHub >

github_iconTop Results From Across the Web

Methods for TypeScript runtime type checking
Explore five methods of performing TypeScript type checks at runtime in this post and learn each of their advantages and disadvantages.
Read more >
Documentation - Advanced Types
A type guard is some expression that performs a runtime check that guarantees the type in some scope.
Read more >
Mastering Type Guards in TypeScript: Making Your Code Safer
Type guards in TypeScript are a powerful feature that allows developers to narrow down the type of a variable at runtime.
Read more >
Enhance your TypeScript with Type Guards
This article embarks on an exploration of Type guards in TypeScript, delving into their intricacies, utilization, and indispensable contribution ...
Read more >
Runtime type checking in TypeScript - learning-notes
Type guards are a way to provide information to the TypeScript compiler by having the code check values at runtime. Some degree of...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found