Skip to content

Conversation

FelixNumworks
Copy link

@FelixNumworks FelixNumworks commented Sep 11, 2025

Fix #24324
Fix #19387
Fix #18585

EDIT:

This PR adds a string_enum_ class to be able to use enums values as plain string in javascript

string_enum_<MyEnum>("MyEnum");

The enum is greatly simplified, since the name of a value is equivalent to the value itself.

MyEnum.ONE === "ONE" // true

foo(MyEnum.ONE);
foo("ONE");

We go from

export interface MyEnumValue<T extends number> {
  value: T;
}
export type MyEnum = MyEnumValue<0>|MyEnumValue<1>|MyEnumValue<2>;

interface EmbindModule {
  MyEnum: {valueOne: MyEnumValue<0>, valueTwo: MyEnumValue<1>, valueThree: MyEnumValue<2>};
};

to

export type MyEnum = 'valueOne'|'valueTwo'|'valueThree'

interface EmbindModule {
  MyEnum: {valueOne: 'valueOne', valueTwo: 'valueTwo', valueThree: 'valueThree'};
};

This doesn't conflict with current implementation of enums, as it's whole new class

This is my first PR to emscripten, so I'm sorry in advance if it contains some obvious flaws ...
I really hope this gets into the main code, as it would really simplify enums handling

@FelixNumworks FelixNumworks changed the title feat(embind): add a way to register enums valus as plain string feat(embind): add a way to register enum values as plain string Sep 11, 2025
@kripken kripken requested a review from brendandahl September 11, 2025 22:56
@FelixNumworks
Copy link
Author

I splitted most of the code between the two, but I kept a common class in the TS types generation, as they are very close to one another

@FelixNumworks FelixNumworks force-pushed the string-enums branch 2 times, most recently from 6bfd0ba to 92d9adb Compare September 22, 2025 08:52
@brendandahl
Copy link
Collaborator

Sorry for the delay here. I got some feedback from a few different projects that also want enum to behave differently and not have a .value. Unfortunately, all the other projects would like the enums in JS to be the integer value, not a string value. Is there reason you want to the string form instead of an int?

On another topic, one downside I see to not using TS enums is you can compare different enums and it will NOT be an error. e.g.

export type Dog = 'a'|'b';
export type Cat = 'a'|'b';

interface EmbindModule {
  Dog: {a: 'a', b: 'b'};
  Cat: {a: 'a', b: 'b'};
};

let module = {} as EmbindModule;

let myDog : Dog = module.Dog.a;

if (myDog == module.Cat.a) { // <--- this is not a compiler error
}

vs

declare enum Dog {
    a = 'a',
    b = 'b',
}

declare enum Cat {
    a = 'a',
    b = 'b',
}

let myDog : Dog = Dog.a;
if (myDog == Cat.a) { < --- compiler error
}

@FelixNumworks
Copy link
Author

FelixNumworks commented Sep 26, 2025

Why I'm not using TS enums

On another topic, one downside I see to not using TS enums is you can compare different enums and it will NOT be an error. e.g.

I didn't find a clean way to bind the enum to a real TS enum.

If I understand well, you suggest doing:

// module.d.ts
declare enum Animal {
    Dog = 1;
    Cat = 2;
}

But since this enum is only declared in a .d.ts file, it won't be accessible at run time. It only exists as a TS type.

The only way to then make this enum declaration usable, is to add this at the root of module.js:

// module.js, outside the module code

const Animal = {
  Dog: 1,
  Cat: 2,
};

//  Add reverse mapping like real TS enums
Animal[1] = 'Dog';
Animal[2] = 'Cat';

export Animal;

This means that:

  • The enum logic is fully reimplemented in plain js
  • The enum values must be declared outside of the module object (how ?)

I didn't like the idea of manually reimplementing what TS usually does under the hood. And even if I wanted to, I didn't know where to do this implementation. Thus, I went for a plain TS type.

I also think that TS types have the benefit over TS enums of being easier to use and to overload. I prefer using them over TS enums in general (but this is a personal preference. TS enums are a bit clunky imo)

I don't think the comparison problem you raised is that much of an issue. In the end, Dog.a and Cat.a are indeed equal ...

Why I'm using strings

Is there reason you want to the string form instead of an int

We saw above that I couldn't easily implement real TS enums, so I went for:

// module.d.ts
export type Animal = 'Dog' | 'Cat';

interface EmbindModule {
  Animal: { Dog: 'Dog', Cat: 'Cat' },
}

Would you suggest replacing it with the following implementation ? I think it's a bit strange to declare a union type of ints like this..

// module.d.ts
export type Animal = 1 | 2;

interface EmbindModule {
    Animal: { Dog: 1, Cat: 2 },
}

Also this is less close to real TS enums.
Indeed, in TS, you have two ways enums behave:

  • Strings: the enum is "one way"
const enum Animal { Dog: "Dog", Cat, "Cat" };
console.log(Object.values(Animal)); // ["Dog", "Cat"]
  • Numerics: the enum also carries the reverse mapping of values:
const enum Animal { Dog: 1, Cat, 2 };
console.log(Object.values(Animal)); // ["Dog", "Cat", 1, 2]

See https://www.typescriptlang.org/docs/handbook/enums.html for details

Since my implementation only used strings, it was closer to a real TS string enum behaviour.

Finally, using plain strings allows me to use the enum values without even needing the module:

const a: Animal = "Dog"; // << doesn't need the module
// vs
const a: Animal = myModule.Animal.Dog: // << needs an instanciated module

With number I would need to do:

const a: Animal = 1; // << What is 1 ? Dog, Cat ? 
// vs
const a: Animal = myModule.Animal.Dog; // << needs an instanciated module to be readable

Possible solution

If people need the int values, I think we can add a parameter in the enum binding (and merge all implementations inside enum_ for clarity)

Int enums:

enum_<Animal>("Animal", enum_value_type::integer)
 .value("Dog", Animal::Dog)
 .value("Cat", Animal::Cat)

emits:

// module.d.ts
export type Animal = 1 | 2;

interface EmbindModule {
    Animal: { Dog: 1, Cat: 2 },
}

String enums:

enum_<Animal>("Animal", enum_value_type::string)
 .value("Dog", Animal::Dog)
 .value("Cat", Animal::Cat)

emits:

// module.d.ts
export type Animal = "Dog" | "Cat";

interface EmbindModule {
    Animal: { Dog: "Dog", Cat: "Cat" },
}

Legacy enums (default):

enum_<Animal>("Animal", enum_value_type::legacy)
 .value("Dog", Animal::Dog)
 .value("Cat", Animal::Cat)

emits:

// module.d.ts
export type AnimalValue<T extends number> {
  value: T
}

export type Animal = AnimalValue<1> | AnimalValue<2>;

interface EmbindModule {
  Animal: { Dog: Animal<1>, Cat: Animal<2> };
}

This handles the various cases. It's not as close to TS enums, but again I don't think it's that much of a problem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants