Skip to content

#[derive(jedem::Enum)]: each language gets a real enum - #9

Merged
zmaril merged 1 commit into
mainfrom
feat/derive-enum
Aug 18, 2026
Merged

#[derive(jedem::Enum)]: each language gets a real enum#9
zmaril merged 1 commit into
mainfrom
feat/derive-enum

Conversation

@zmaril

@zmaril zmaril commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Stacked on #8. The last of the five, and the one whose cost was never really
about lines.

jawohl had to write a describe() converter turning Validation into a
String, so Python received 'irrecoverably_invalid' as a bare string and
TypeScript received string. The API was degraded to fit the vocabulary.

hello.classify(20)              # Ripeness.Done      — a real class
hello.is_settled("Done")        # TypeError          — a bare string is rejected
hello.classify(20)              // "Done"            — a TS string-literal union
hello.isSettled("NotAVariant")  // throws

Python gets a pyclass registered on the module; TypeScript gets napi's
string_enum. Both reject non-variants — a typo is caught by that language's
own tooling instead of surfacing as a value nobody matched. Variants take an
optional boundary spelling via #[jedem(name = "...")].

Three things the implementation forced

Wire names must be valid identifiers. My first draft allowed "n/a", which
made the pyclass macro fail to expand and cascaded into seven confusing errors
in Python only — the worst place to find out. A variant has to be nameable
in every target, so the derive rejects non-identifiers with a message saying why.

From<A> for B does not give Option<A> → Option<B>. Conversion is mapped
through containers by a shared convert() helper, so Option<Ripeness> and
Vec<Ripeness> work rather than failing at the binding's compile step.

Enums are collected by walking the ops, not listed in surface!, because the
macro cannot resolve types. That also means an enum reached only through an
Option is still declared — otherwise a binding names a type it never defined.

And a nicer error for everything else

An unknown named type now lowers as an enum, with the curated "jedem cannot lower
this" message moved to a diagnostic::on_unimplemented on the EnumType trait —
so a genuinely unsupported type still gets the explanation, including that there
is deliberately no opaque-blob fallback.

Adding the Type variant made both backends fail to compile until they
handled it. That is the exhaustiveness safety net working as designed.

43 tests, clippy clean, both round-trips exercising enums as returns, as
parameters, and inside Option.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HsDxLrGdx6nPaXkVEWkNvS

Base automatically changed from feat/boxed-error to main August 18, 2026 20:47
…tring

The last item, and the one whose cost was never really about lines. jawohl had
to write a describe() converter turning Validation into a String, so Python
received 'irrecoverably_invalid' as a bare string and TypeScript received
`string`. The API was degraded to fit the vocabulary.

Now a C-like enum crosses as that language's own enum. Python gets a real class
via pyclass, registered on the module; TypeScript gets a string-literal union
via napi's string_enum. Both reject a value that is not a variant, which is the
whole point -- a typo is caught by the language's own tooling rather than
surfacing as a value nobody matched.

Variants carry an optional boundary spelling, pinned with #[jedem(name = ...)]
for values whose wire form is already fixed.

Three things the implementation forced, each worth keeping:

Wire names must be valid identifiers. A first draft allowed "n/a", which made
the pyclass macro fail to expand and cascaded into seven confusing errors in
Python only -- the worst place to discover it. A variant has to be NAMEABLE in
every target, so the derive now rejects non-identifiers with a message saying
why.

From<A> for B does not give Option<A> -> Option<B>. Enum conversion is mapped
through containers by a shared convert() helper, so Option<Ripeness> and
Vec<Ripeness> work rather than failing at the binding's compile step.

Enums are collected by walking the ops rather than listed in surface!, because
the macro cannot resolve types. That also means an enum reached only through an
Option or a Vec is still declared -- otherwise a binding names a type it never
defined.

An unknown named type in a signature now lowers as an enum, and the curated
"jedem cannot lower this" message moves to a diagnostic::on_unimplemented on the
EnumType trait, so a genuinely unsupported type still gets the explanation
including that there is deliberately no opaque-blob fallback.

Adding the Type variant made both backends fail to compile until they handled
it, which is the exhaustiveness safety net working as intended.

43 tests, clippy clean, both host round-trips exercising enums as returns, as
parameters, and inside Option.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsDxLrGdx6nPaXkVEWkNvS
@zmaril
zmaril merged commit a07d759 into main Aug 18, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant