Getting started
Install
One package for the enum itself, plus the adapter for your data layer if you have one.
npm i @real-enums/core
| Data layer | Package |
|---|---|
| WarpDrive / ember-data | @real-enums/warp-drive |
| TanStack Query | @real-enums/tanstack-query |
| SWR | @real-enums/swr |
| RTK Query | @real-enums/rtk-query |
| Apollo Client | @real-enums/apollo |
| urql (Graphcache) | @real-enums/urql |
| Anything else |
mapFields / codec(), in @real-enums/core
|
Getting started
Define an enum
An enum is a class. Each value is a static member declared with
Cls.of(value); everything else is an ordinary getter.
import { Enum, is, isNot } from '@real-enums/core'
export class OrderState extends Enum {
static Pending = OrderState.of('pending')
static Shipped = OrderState.of('shipped')
static Delivered = OrderState.of('delivered')
static Cancelled = OrderState.of('cancelled')
@is() accessor isPending!: boolean
@is() accessor isCancelled!: boolean
@isNot() accessor isNotCancelled!: boolean
get next(): OrderState | undefined {
return {
[OrderState.Pending.value]: OrderState.Shipped,
[OrderState.Shipped.value]: OrderState.Delivered,
}[this.value]
}
get advanceLabel(): string | undefined {
return {
[OrderState.Pending.value]: 'Ship',
[OrderState.Shipped.value]: 'Deliver',
}[this.value]
}
get className() {
return `badge badge--${this.value}`
}
}
Members are singletons, so === works. They extend String, so
they interpolate as the raw value and JSON.stringify writes it.
OrderState.values // [Pending, Shipped, Delivered, Cancelled]
OrderState.valueFor('shipped') // OrderState.Shipped
OrderState.valueFor('archived') // undefined
OrderState.Shipped.label // 'Shipped'
JSON.stringify({ state: OrderState.Shipped }) // '{"state":"shipped"}'
new OrderState('pending') makes the same instance, but its type is plain
OrderState, so match cannot tell it from
the other members. Use of.
Using enums
Behaviour on the value
The label, the badge class and what comes next are getters, so templates ask the value
instead of repeating an if ladder wherever the state appears.
<span class={{order.state.className}}>{{order.state.label}}</span>
{{#if order.state.advanceLabel}}
<button>{{order.state.advanceLabel}}</button>
{{/if}}
{{#each OrderState.values as |state|}}
<option value={{state.value}}>{{state.label}}</option>
{{/each}}
A lookup that only some members answer, like next, is a table keyed by
value. When every member needs an answer, use
match, so a new member cannot be forgotten.
Using enums
Predicates
@is() and @isNot() are booleans named after a static member. A
name with no matching member throws on first read rather than always answering
false.
// Standard decorators: TypeScript 5+, esbuild, Babel's 2023-11 plugin.
@is() accessor isPending!: boolean
// Ember's legacy decorators (decorator-transforms).
@is() declare isPending: boolean
Using enums
Exhaustive matching
Cls.match(value) branches on a member with the compiler checking that every
member is handled, like a Scala match on a sealed type. Cases are the static
members themselves, never strings, and one case can take several.
const statusText = (state: OrderState) =>
OrderState.match(state)
.when([OrderState.Pending, OrderState.Shipped], () => 'In progress')
.when([OrderState.Delivered], () => 'Done')
.when([OrderState.Cancelled], () => 'Cancelled')
.exhaustive()
const canEdit = (state: OrderState) =>
OrderState.match(state)
.when([OrderState.Pending], () => true)
.otherwise(() => false)
What the compiler catches
| Mistake | What tsc says |
|---|---|
| A member is not handled |
.exhaustive() is not callable; its type is
{ unhandled: … }, naming the members left.
|
| A member is handled twice |
The second .when rejects it: only unhandled members are accepted.
|
| A string instead of a member | Rejected: cases are members. |
A member declared with new |
.match() returns an error type telling you to declare members with
Cls.of().
|
Adding a member breaks every .exhaustive() on that enum until it is handled,
which is the point. .otherwise(fn) is the catch-all and turns the check off
for that match. The first .when that lists the value wins, and its handler
gets the value, typed as the members it listed.
Why not if or switch?
TypeScript narrows === only for literal types like strings, not for objects,
so an if chain or switch over members is never narrowed, and no
lint rule can tell a case is missing. match gets each member its own type
from of and tracks which are left itself.
Where it is checked
In tsc. Vite and Babel strip types without checking them, so run
tsc --noEmit in CI. Plain JavaScript and templates get the runtime only,
where .exhaustive() throws for a value no case listed. A value typed
any skips the check.
Limits
-
TypeScript types are structural, so a member of a subclass of the enum type-checks as
one of its members.
.exhaustive()throws for it at runtime. -
ofneeds a public constructor. It passes extra constructor arguments through (Icon.of('open', '🟢')), but an enum with aprivate constructordeclares members withnewand cannot usematch. -
ofthrows if the class already has a member with that value, since two members with one value would share a type.
Going further
Data layers
Your API sends strings. Each adapter turns them into members on the way in and back into strings on the way out, and is a few lines over one shared mapper.
-
Ember: a WarpDrive Transformation for schema records and a transform
for
@attron Models. Setup. -
React: TanStack Query
select, SWR fetchers, RTK QuerytransformResponse, Apollo field policies and urql resolvers. Setup.
Anywhere else, map the fields yourself:
import { mapFields } from '@real-enums/core'
// Dotted paths; arrays are transparent at the root, along the path and at the leaf.
const orders = mapFields<Order[]>(json, { state: OrderState, 'lines.status': LineStatus })
// One field, either way. Unknown and missing values parse to undefined.
const { parse, serialize } = OrderState.codec()
Going further
Validation
realEnum(Cls) is a strict Standard Schema v1 validator, for anything that
takes one: TanStack Form, tRPC, Hono and others. Anything but one of the enum's values or
members is an issue.
import { realEnum } from '@real-enums/core'
realEnum(OrderState)['~standard'].validate('pending') // { value: OrderState.Pending }
realEnum(OrderState)['~standard'].validate('nope') // { issues: [...] }
Going further
For AI agents
If an AI coding agent writes code against real-enums, point it at
llms.txt, the same rules in plain text. The short version:
- Declare members with
Cls.of('value'), nevernew. -
When every member needs an answer, use
Cls.match(value)and end with.exhaustive(). Notif/else, notswitch, not a lookup table. Tables keyed byvalueare for lookups only some members answer, likenext. -
Cases are static members (
OrderState.Pending), never strings ('pending'). -
Use
.otherwise()only when a fallback is truly intended; it turns the check off. -
Compare members with
===or a predicate likeisPending, not with their string value. -
Fix a
matchtype error by handling the member it names, never with a cast or@ts-expect-error.
Reference
API
| Member | What it does |
|---|---|
Cls.of(value) |
Declares a member, typed as that member alone. |
Cls.match(value) |
An exhaustive match: .when([members], fn),
.exhaustive(), .otherwise(fn).
|
value, label, id |
The raw string; it capitalized (override it); the instance itself, for option values in templates. |
Cls.values |
Every static member, in declaration order. |
Cls.valueFor(raw) |
The member for a raw value, or undefined. Takes a member too. |
@is(), @isNot() |
Predicates named after a static member. |
Cls.codec() |
{ parse, serialize }, lenient: unknown values parse to
undefined.
|
mapFields(data, map) |
A copy of data with dotted paths mapped to members. |
realEnum(Cls) |
A strict Standard Schema v1 validator. |
Every detail is in the package README.