Skip to content

Instantly share code, notes, and snippets.

@jeremy-code
Last active May 7, 2025 01:56
Show Gist options
  • Save jeremy-code/c54f19402456466c4cf79b2bc7d0af96 to your computer and use it in GitHub Desktop.
Save jeremy-code/c54f19402456466c4cf79b2bc7d0af96 to your computer and use it in GitHub Desktop.
Emscripten Embind Enums

Suppose you are wanting to use Emscripten's Embind with an Enum.

// exif-byte-order.h
typedef enum {
	EXIF_BYTE_ORDER_MOTOROLA,
	EXIF_BYTE_ORDER_INTEL
} ExifByteOrder;
// .cpp
#include <libexif/exif-byte-order.h>
#include <emscripten/bind.h>

using namespace emscripten;

EMSCRIPTEN_BINDINGS(Enum) {
    enum_<ExifByteOrder>("ExifByteOrder")
        .value("MOTOROLA", EXIF_BYTE_ORDER_MOTOROLA)
        .value("INTEL", EXIF_BYTE_ORDER_INTEL)
        ;
}

This would be the resulting declaration TypeScript file.

// module.d.ts
export interface ExifByteOrderValue<T extends number> {
  value: T;
}
export type ExifByteOrder = ExifByteOrderValue<0>|ExifByteOrderValue<1>;

interface EmbindModule {
  ExifByteOrder: {MOTOROLA: ExifByteOrderValue<0>, INTEL: ExifByteOrderValue<1>};
}

So far so good, let's check what the console tells us:

console.log("ExifByteOrder", ExifByteOrder);
console.log("typeof ExifByteOrder", typeof ExifByteOrder);
console.log("Object.keys(ExifByteOrder)", Object.keys(ExifByteOrder));
console.log("ExifByteOrder['MOTOROLA'].value", ExifByteOrder["MOTOROLA"].value);
console.log("ExifByteOrder['INTEL'].name", ExifByteOrder["INTEL"].value);
node index.js
ExifByteOrder [Function: ctor] {
  values: { '0': ctor {}, '1': ctor {} },
  argCount: undefined,
  MOTOROLA: ctor {},
  INTEL: ctor {}
}
typeof ExifByteOrder function
Object.keys(ExifByteOrder) [ 'values', 'argCount', 'MOTOROLA', 'INTEL' ]
ExifByteOrder['MOTOROLA'].value 0
ExifByteOrder['INTEL'].name 1

...huh? I have no idea why it's a function (long shot guess, it's because WASM can only export functions and WebAssembly.Memory) but judging by the name "ctor", I'd guess it has something to do with the EVAL_CTORS option. Moreover, the argCount and values property do intrigue me.

Since it is a function, let's see how it reacts to typical function behavior (despite TypeScript's insisitence on it not existing)

console.log("ExifByteOrder.length", ExifByteOrder.length);
console.log("ExifByteOrder.name", ExifByteOrder.name);
console.log("ExifByteOrder.prototype", ExifByteOrder.prototype);
console.log("ExifByteOrder()", ExifByteOrder());
console.log("ExifByteOrder.toString()", ExifByteOrder.toString());
node index.js
ExifByteOrder.length 0
ExifByteOrder.name ctor
ExifByteOrder.prototype {}
ExifByteOrder() undefined
ExifByteOrder.toString() function ctor() {}

I'm going to assume at this point the pattern for the JavaScript is obvious and only show the output:

node index.js
ExifByteOrder.values { '0': ctor {}, '1': ctor {} }
typeof ExifByteOrder.values object
ExifByteOrder.values['1'] ctor {}
typeof ExifByteOrder.values['1'] object
ExifByteOrder['MOTOROLA'] ctor {}
typeof ExifByteOrder['MOTOROLA'] object
ExifByteOrder['INTEL'] ctor {}
typeof ExifByteOrder['INTEL'] object

Interesting how they're all objects. Let's see what values they have.

node index.js
Object.entries(ExifByteOrder.values) [ [ '0', ctor {} ], [ '1', ctor {} ] ]
Object.entries(ExifByteOrder.values['1']) []
Object.entries(ExifByteOrder['MOTOROLA']) []
Object.entries(ExifByteOrder['INTEL']) []

Ok, so nothing, perhaps it has non-enumerable properties?

node index.js
Object.getOwnPropertyDescriptors(ExifByteOrder.values) {
  '0': {
    value: ctor {},
    writable: true,
    enumerable: true,
    configurable: true
  },
  '1': {
    value: ctor {},
    writable: true,
    enumerable: true,
    configurable: true
  }
}
Object.getOwnPropertyDescriptors(ExifByteOrder.values['1']) {
  value: { value: 1, writable: false, enumerable: false, configurable: false },
  constructor: {
    value: [Function: ExifByteOrder_INTEL],
    writable: false,
    enumerable: false,
    configurable: false
  }
}
Object.getOwnPropertyDescriptors(ExifByteOrder['MOTOROLA']) {
  value: { value: 0, writable: false, enumerable: false, configurable: false },
  constructor: {
    value: [Function: ExifByteOrder_MOTOROLA],
    writable: false,
    enumerable: false,
    configurable: false
  }
}
Object.getOwnPropertyDescriptors(ExifByteOrder['INTEL']) {
  value: { value: 1, writable: false, enumerable: false, configurable: false },
  constructor: {
    value: [Function: ExifByteOrder_INTEL],
    writable: false,
    enumerable: false,
    configurable: false
  }
}

Well... neat. I have no idea what this means. But anyway, if you're looking for code to make this look more like a reasonable enum or object (though you lose the reverse mapping which might be what the .values is for...? I should've tested it with an enum that had better values)

/**
 * Before the C23 standard, enums were not allowed to have a value that was not
 * an `int`
 *
 * @see {@link https://open-std.org/JTC1/SC22/WG14/www/docs/n3029.htm}
 */
interface EmbindEnumValue<T> {
  value: T;
}

/**
 * Maps an enum generated from Emscripten's Embind to a plain object with the
 * key-value pairs from the enum
 *
 * @see {@link https://emscripten.org/docs/porting/connecting_cpp_and_javascript/embind.html#enums}
 * @see {@link https://github.com/emscripten-core/emscripten/blob/main/src/lib/libembind_gen.js#L277-L310}
 */
const mapEmbindEnumToObject = <
  Enum extends Record<PropertyKey, EmbindEnumValue<unknown>>,
>(
  embindEnum: Enum,
) =>
  Object.fromEntries(
    Object.entries(embindEnum)
      .filter(([key]) => !["values", "argCount"].includes(key))
      .map(([key, value]) => [key, value.value]),
  ) as {
    [K in keyof Enum]: Enum[K] extends { value: infer V } ? V : never;
  };

export { mapEmbindEnumToObject };

I am really not a fan of the cast but there's really no way around it using Object.fromEntries(). If you wanted runtime error handling you probably should check if typeof enumObj === 'function'.

I don't know if this behavior is expected or will change in the future. Currently the docs only have two sentences for Enums, those being

Embind’s enumeration support works with both C++98 enums and C++11 “enum classes”. In both cases, JavaScript accesses enumeration values as properties of the type.

@jeremy-code
Copy link
Author

I've been thinking a lot about this and there's a lot of quirks and pain points with what I've been doing. I may elaborate further on this in the future, but for now, let me try a slightly more complicated version of my mapEmbindEnumToObject function.

import type { EmbindEnum, EmbindEnumValue } from "../interfaces.ts";

type EmbindEnumObject<T extends EmbindEnum> = {
  readonly [Key in keyof T]: T[Key] extends EmbindEnumValue<infer Value> ? Value
  : never;
} & {
  readonly [Symbol.iterator]: () => ArrayIterator<
    [keyof T, T[keyof T]["value"]]
  >;
};

/**
 * Maps an enum generated from Emscripten's Embind to a plain object with the
 * key-value pairs from the the enum's keys `.value` property. Adheres to the
 * ECMAScript enum proposal.
 *
 * @see {@link https://emscripten.org/docs/porting/connecting_cpp_and_javascript/embind.html#enums}
 * @see {@link https://github.com/emscripten-core/emscripten/blob/main/src/lib/libembind_gen.js#L277-L310}
 * @see {@link https://github.com/tc39/proposal-enum}
 */
const mapEmbindEnumToObject = <Enum extends EmbindEnum>(embindEnum: Enum) => {
  const entries = Object.entries(embindEnum)
    .filter(([key]) => key !== "values" && key !== "argCount")
    .map<[string, unknown]>(([key, value]) => [key, value.value]);

  const enumObject = Object.fromEntries([
    ...entries,
    [Symbol.iterator, entries[Symbol.iterator].bind(entries)],
  ]);

  Object.setPrototypeOf(enumObject, null);
  Object.preventExtensions(enumObject);

  return enumObject as EmbindEnumObject<Enum>;
};

export { type EmbindEnumObject, mapEmbindEnumToObject };

The largest changes are:

The idea is to bring it closer in line with the ECMAScript proposal for Enums (see tc39/proposal-enum). It's not perfect, namely [Symbol.toStringTag] being the name of the enum I don't think is possible, but the changes fall in line with the proposed standard.

Making it iterable is actually very useful since doing reverse lookups or iterating through all members of enum in my experience occurred very frequently, and now it is a lot more simplified by just using Array.from(enum) instead of using Object.entries repeatedly. However, this does mean that instead of being able to use keyof typeof Enum, you do have to do something like type EnumKey = Exclude<keyof typeof Enum, symbol> but I think that's a fine tradeoff.

The null prototype and non-extensibility is just to bring it in line with the proposal. I would like to say so that this could be a drop-in change at some point in the future when it is implemented, but since it is currently in Stage 1 and was last presented April 2025, I suspect we will be waiting a while.

@jeremy-code
Copy link
Author

import type { EmbindEnum, EmbindEnumValue } from "../interfaces.ts";

type EmbindEnumObjectIterator<T extends EmbindEnum> = {
  readonly [Symbol.iterator]: () => ArrayIterator<
    [keyof T, T[keyof T]["value"]]
  >;
};

type EmbindEnumObject<T extends EmbindEnum> = {
  readonly [Key in keyof T]: T[Key] extends EmbindEnumValue<infer Value> ? Value
  : never;
} & EmbindEnumObjectIterator<T>;

/**
 * Maps an enum generated from Emscripten's Embind to a plain object with the
 * key-value pairs from the the enum's keys `.value` property. Adheres to the
 * ECMAScript enum proposal.
 *
 * @see {@link https://emscripten.org/docs/porting/connecting_cpp_and_javascript/embind.html#enums}
 * @see {@link https://github.com/emscripten-core/emscripten/blob/main/src/lib/libembind_gen.js#L277-L310}
 * @see {@link https://github.com/tc39/proposal-enum}
 */
const mapEmbindEnumToObject = <Enum extends EmbindEnum>(
  embindEnum: Enum,
): EmbindEnumObject<Enum> => {
  const entries = Object.entries(embindEnum)
    .filter(([key]) => key !== "values" && key !== "argCount")
    .map<[string, unknown]>(([key, value]) => [key, value.value]);

  const enumObject = Object.assign(
    Object.create(null),
    Object.fromEntries(entries),
  );

  Object.defineProperty(enumObject, Symbol.iterator, {
    value: entries[Symbol.iterator].bind(entries),
    enumerable: false,
  });
  Object.preventExtensions(enumObject);

  return enumObject;
};

export { type EmbindEnumObject, mapEmbindEnumToObject };

Biggest change is that making enumObject[Symbol.iterator] non-enumerable, just like Arrays and using Object.assign(Object.create(null), ...) instead of directly modifying prototype.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment