The interop of discriminated unions between ReScript and TypeScript

One of the killer feature of ReScript is built-in support of discriminated unions and pattern match. TS has something close so that we can model discriminated unions in both languaguages without copying.

Here are some ideas to encode the discriminated unions in both languages without coping:

type data = 
    | A ({name : string})
    | B ({value : number})
    | C
    | D

The corresponding TS encoding would be:

enum dataNonNull = { A, B }
enum dataNull = { C, D}
type data = 
  | { tag : dataNonNull.A, name : string}
  | { tag : dataNonNull.B, name : string}
  | dataNull.C
  | dataNull.D  

The use case is that when you export libraries written in ReScript to TypeScript.

9 Likes

Welcome back and congratulations!

I think this is a great idea. Only thing is I don’t think you need to split the enums up here, one should be okay, and TS will still do the type checking just fine.

You actually need it to ensure the data representation is exactly the same

Ah that’s a good point. I assumed you generated a .d.ts, you could just specify an enum exists. I guess you already tried that because TS complains if it isn’t told what the enum values are. The best I could do was lie,

.d.ts
// File.d.ts
export enum DataTag {
  A = 'A',
  B = 'B',
  C = 'C',
  D = 'D',
}

type Data =
  | { tag: DataTag.A; name: string }
  | { tag: DataTag.B; value: number }
  | DataTag.C
  | DataTag.D;
.js
export const DataTag = { A: 0, B: 1, C: 0, D: 1 };

Is the aim to generate a .ts file - or to generate .js files with an associated .d.ts? The latter is definitely more convenient to work with - but TS definitely makes it more difficult here.

Maybe with the .d.ts route, it would be okay to lie for now, raise an issue with TS, and hope this project has enough clout for Microsoft to consider the use-case.

This would be great. I was looking at using polymorphic variants since the JS they look like

{
   NAME: <variant_name>,
   VAL: <payload>,
}

but if we can make regular variants codegen directly discriminated unions that’s even better.

It would be nice if there were a way to control whether tag or something else like type or kind was used, maybe using a decorator.

Is it safe to assume if a record contained tag as a key then that would be parse error?

Would it be possible to have C and D codegen to objects as well?

type data = 
  | { tag : dataNonNull.A, name : string}
  | { tag : dataNonNull.B, name : string}
  | { tag : dataNull.C}
  | { tag : dataNull.D}

This would be more natural in TypeScript.

1 Like

The use case is that when you export libraries written in ReScript to TypeScript

Isn’t this use case fairly well served with genType at the moment?

@genType
type data = 
    | A ({name : string})
    | B ({value : float})
    | C
    | D

Gives

export type data =
    "C"
  | "D"
  | { tag: "A"; value: { readonly name: string } }
  | { tag: "B"; value: { readonly value: number } };

I must also voice a preference for {tag, data} encoding over {tag, ...data}

for example I think the suggested encoding would have trouble representing something like this on the rescript side:

type payload = {field: int}
type command = DoSomething(payload)
type msg = DidSomething(payload)
switch command {
| DoSomething(data) => DidSomething(data)
}

Note this is not a proposal to change the encoding, it is something that works as is today.
The key is that such transformation is mechanical and no copying needed, that is you don’t need a conversion function. (gentype still requires an conversion under the hood), this is useful when you own the data.

How would this work with the differences between nominal and structural typing in ReScript and TypeScript?

For example these two types in ReScript would translate to two structurally equivalent types, which could be confusing?

type dataA = 
    | A ({name : string})
    | B ({value : number})
    | C
    | D

type dataB = 
    | A ({name : string})
    | B ({value : number})
    | C
    | D

Playground here: https://www.typescriptlang.org/play?ssl=23&ssc=60&pln=1&pc=1#code/KYOwrgtgBAJghgFzgOQPYmWANlgglAbylwBooAhKAXwChRJZEVs9CoBhMgEVoQE8ADsEZJ8AXig0oUAD5skAcygAuESnSYcuAHSkoIOBGGqAzggBOASxALa0uUUUq1aDCx3kyBo8-AQARsDmdrIu7trsUqHwSJp42lzSNHR+LhoslER6lLT00DHMOJkc3LyCwgWUElEOUE6qBa5xOnrexlBmVjYhtfVpblranvqG7X6BwTVhg5H20-GJkjQwwADGWHDmwgBmYCCrCJboUHCrq8ACCOQAFAUNTOQAlKoAbqiWMMkr65vCq+hmE73UTJU7nS43OCPKAAehh+lQUCC5lQ5lBZwuV2ujjgCmB6gG8Vao1UAHIABbAHCoUnUaFwhFI8wotFAA

There’s also the scenario of a type that would assignable to another type in TypeScript because it’s a subtype.

EDIT: Oh, I didn’t know this was already supported by gen-type. I’ve misunderstood the context here!

Right, I got confused because your example had:

{tag: dataNonNull.A, name: string}

Where I was expecting

{tag: dataNonNull.A, data: {name: string}}

You can check the JS output, for variants encoding, the name field is unboxed, so it is : {tag .., name : string}

This doesn’t seem to be the case in 9.1.2, is this something that’s in main but not released yet?

Example: https://rescript-lang.org/try?code=C4TwDgpgBAJg9gcygXigbwHYEMC2EBcUAzsAE4CWGCAvgFC2iRQDGWwK62ehJFVdDcNHaoAIogAU8BAEooAHygBhNhNbAZ9ADYR2WDORxYtHcQgmZcBKACIAYuXg3qmoA

I think this only works when using inline records:
https://rescript-lang.org/try?code=C4TwDgpgBMULxQCIHsDmAKA3gOwIYFsIAuKAZ2ACcBLbVAXwEooAfKAYV2Cz0JPOtqMAUEIA2EWLmxV8uUfCRpuBYlABEAMSoATZGuFA

2 Likes

Would we ever consider changing the encoding? The current encoding makes variants more difficult to consume in TypeScript than they would be if all variants were objects with a tag property.

Current

if (typeof data === "object") {
  switch (data.tag) {
    case DataTag.A:
      // ...
      break;
    case DataTag.B:
      // ...
      break;
  }
} else {
  switch (data) {
    case DataTag.C:
      // ...
      break;
    case DataTag.D:
      // ...
      break;
  }
}

Proposed

switch (data.tag) {
  case DataTag.A:
    // ...
    break;
  case DataTag.B:
    // ...
    break;
  case DataTag.C:
    // ...
    break;
  case DataTag.D:
    // ...
    break;
}

If there are perf concerns we could make this the proposed behaviour opt-in with decorator on the type.

2 Likes

Maybe it should be reversed: the decorator could be called @boxed, and the default code is the performant code.

1 Like

@jacobp100 agreed. I’ve edited my comment to be clearer.