Skip to content

Conversation

FelixNumworks
Copy link

@FelixNumworks FelixNumworks commented Sep 11, 2025

Fix #24324
Fix #19387
Fix #18585

EDIT:

This PR adds a param to enum_ to be able to use enums values as plain string or plain numbers in javascript

Int enums:

enum_<Animal>("Animal", enum_value_type::number)
 .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" },
}

Object enums (default):

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

 // OR
 
enum_<Animal>("Animal") 
 .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 doesn't conflict with current implementation of enums, as the parameter default value is the one keeping the same behaviour

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

@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.

@FelixNumworks
Copy link
Author

Hi :) Any new on this @brendandahl ? Do you want me to develop the change I suggested above ?

@brendandahl
Copy link
Collaborator

brendandahl commented Oct 7, 2025

I also can't see a way to make the TS enums work well in a definition file, so the suggested change sounds good. For enum_value_type::legacy, I'd lean towards calling that enum_value_type::default or enum_value_type::value.

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 ...

FWIW, that misses the point of strong type safety and why the enum class feature was added to c++. Accidently comparing the wrong enums can lead to unexpected behavior.

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

I'd also suggest against doing this. Another value of enums is you can change the underlying values and not have to update your code every place that you hardcoded the value.

Copy link
Collaborator

@brendandahl brendandahl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, just a few things to address.

});
});

BaseFixture.extend("enums with string values", function() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a test for integers enums as well?

}
export type Bar = BarValue<0>|BarValue<1>|BarValue<2>;

export type Baz = 'valueA'|'valueB'|'valueC';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the definition files need to be updated.

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