Untagged unions where more than one variant is an object

Spent some time trying to figure this out and did end up installing rescript@next to get the new support for untagged unions with @unboxed. However this doesn’t solve my use-case where I’m trying to bind to a typescript library. The untagged union is simply:

type VersionedMessage = Message | MessageV0

I attempt to bind to rescript with:

  type versionedMessage = Message(message) | Message0(message0)

Where message and message0 are records/objects but I get this error:

  74 ┆ @unboxed
  75 ┆ type versionedMessage = Message(message) | Message0(message0)
  76 ┆
  77 ┆ let t = #Message("the message")

  This untagged variant definition is invalid: At most one case can be an object type.

Even if the new untagged unions won’t cover this use-case does anyone use any workarounds when writing bindings to something like this?

With limited knowledge of the JS in question, I think the best method in this case is to write two bindings to the same function

// assuming Message is a JS object and has as a function called `helloWorld`
module Message = {
  type t = message
  @send external helloWorld: t => unit = "helloWorld"

module MessageV0 = {
  type t = message0
  @send external helloWorld: t => unit = "helloWorld"

then you could use it like

let m: message = /* Some message type */


let m0: message0 = /* Some messageV0 type */


You’ll need to show the TS definitions too.

My apologies. For additional context these are not objects/classes that I’d be creating but created on the TS side that rescript would be consuming. Working on solana/web3.js and related solana-wallet-adapter bindings.

  export class Message {
    header: MessageHeader;
    accountKeys: PublicKey[];
    recentBlockhash: Blockhash;
    instructions: CompiledInstruction[];
    private indexToProgramIds;
    constructor(args: MessageArgs);
    get version(): 'legacy';
    get staticAccountKeys(): Array<PublicKey>;
    get compiledInstructions(): Array<MessageCompiledInstruction>;
    get addressTableLookups(): Array<MessageAddressTableLookup>;
    getAccountKeys(): MessageAccountKeys;
    static compile(args: CompileLegacyArgs): Message;
    isAccountSigner(index: number): boolean;
    isAccountWritable(index: number): boolean;
    isProgramId(index: number): boolean;
    programIds(): PublicKey[];
    nonProgramIds(): PublicKey[];
    serialize(): Buffer;
     * Decode a compiled message into a Message object.
    static from(buffer: Buffer | Uint8Array | Array<number>): Message;

  export class MessageV0 {
    header: MessageHeader;
    staticAccountKeys: Array<PublicKey>;
    recentBlockhash: Blockhash;
    compiledInstructions: Array<MessageCompiledInstruction>;
    addressTableLookups: Array<MessageAddressTableLookup>;
    constructor(args: MessageV0Args);
    get version(): 0;
    get numAccountKeysFromLookups(): number;
    getAccountKeys(args?: GetAccountKeysArgs): MessageAccountKeys;
    isAccountSigner(index: number): boolean;
    isAccountWritable(index: number): boolean;
      addressLookupTableAccounts: AddressLookupTableAccount[],
    ): AccountKeysFromLookups;
    static compile(args: CompileV0Args): MessageV0;
    serialize(): Uint8Array;
    private serializeInstructions;
    private serializeAddressTableLookups;
    static deserialize(serializedMessage: Uint8Array): MessageV0;

Perfect, you have a discriminator on version. Here’s how you bind to it zero cost using (regular) variants and inline records. Notice I’ve only added a single field to each object, just to show how it works.

type message =
  | @as(0) MessageV0({numAccountKeysFromLookups: int})
  | @as("legacy") Message({isProgramId: int => bool})

let checkMessage = (message: message) =>
  switch message {
  | MessageV0({numAccountKeysFromLookups}) =>
  | Message({isProgramId}) => Console.log(isProgramId(1))

Playground link: ReScript Playground

It generates this JS, so you can see it’s actually checking version before doing anything:

function checkMessage(message) {
  if (message.version === 0) {
    return ;

Quick breakdown of how it works:

  • @tag("version") says “use the version field to discriminate between variant constructors that are objects”
  • @as() controls what value the discriminator version is supposed to have for each constructor. You can mix and match here (can even put undefined or null in there), it doesn’t have to be all of the same type.
  • Inline records mean that the constructor expects the fields to be found on the object itself directly. This is important here, with a normal record type as payload it’d expect all fields to be on the _0 field (internal ReScript thing).

@zth since these are mapping to classes, I am not sure if using inline records would work to express methods, since the this context would be messed up, no?

No, I don’t think that’s the case. Look at the emitted JS above, it’s a regular method call, just like you’d do in hand written JS code. But trying it out for real is the only way to find out I guess.

EDIT: It’s worth noting though that this isn’t exactly 1:1 with “getting a class instance and passing it around”, and maybe that’s what you mean too @ryyppy . Inline records won’t let you pass along the whole record as you destructure it, only parts. So if you need to be able to pass it around as you would an object then yeah, you’ll need to resort to something more along the lines of what @YKW said.

Thank you all so much!

I’ve come across another situation like this that has me a bit stuck.

Writing bindings for framer-motion, there is a prop that looks like this

type BoundingBox = {top: int, right: int, bottom: int, left: int}
 dragConstraints?: false | Partial<BoundingBox> | RefObject<Element>;

Unfortunately, there is no shared field in this, so @tag is off the table.

I could write two components, one with RefObject and one with BoundingBox. That works for now, but I believe it won’t scale very well