{{#if (eq order.state "pending")}}
<span class="amber">Pending</span>
<button>Ship</button>
{{else if (eq order.state "shipped")}}
<span class="blue">Shipped</span>
<button>Deliver</button>
{{else if (eq order.state "delivered")}}
<span class="green">Delivered</span>
{{/if}}
Why
Ask the value, not the template
A status that arrives as a string gets the same if ladder in every template
that shows it, and each copy drifts. Put the answers on the value once, and every template
asks it instead.
<span class={{order.state.className}}>
{{order.state.label}}
</span>
{{#if order.state.advanceLabel}}
<button>{{order.state.advanceLabel}}</button>
{{/if}}
And the dropdown builds itself:
{{#each OrderState.values as |state|}}
<option value={{state.value}}>{{state.label}}</option>
{{/each}}
Behaviour on the value
Labels, classes, icons and transitions are getters. Add a state in one place.
Singletons
=== works, and caches keep references stable when a refetch returns the
same payload.
Strings on the wire
Instances extend String, so JSON.stringify sends the raw
value. The TanStack, Apollo, urql and WarpDrive schema-record adapters keep their
caches plain JSON.
Lenient, or strict
A state the client doesn't know yet reads undefined, not a crash. Ask for
strict with a Standard Schema validator.
Define one
An enum is a class with static members
Each value is a static instance. Everything else is an ordinary getter, so it can be as rich as the domain needs.
import { Enum, is, isNot } from 'real-enums'
export class OrderState extends Enum {
static Pending = new OrderState('pending')
static Shipped = new OrderState('shipped')
static Delivered = new OrderState('delivered')
static Cancelled = new OrderState('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}`
}
}
OrderState.valueFor('shipped') // OrderState.Shipped
OrderState.values // [Pending, Shipped, Delivered, Cancelled]
OrderState.Shipped.label // 'Shipped'
JSON.stringify({ state: OrderState.Shipped }) // '{"state":"shipped"}'
@is() and @isNot() are named after the static member they test.
Lookups like next are tables keyed by value, so adding a state
is one line per table. In Ember, where decorators are compiled with legacy semantics,
write @is() declare isPending: boolean; Vite 8 leaves decorators alone, so a
React app on Vite adds a small transform (the examples show one).
Ember
WarpDrive and ember-data
@real-enums/warp-drive has a Transformation for schema records and a
transform for @attr on Models. Both turn the cached string into an enum on
read and back into the string on save. Works with WarpDrive 5.8 and later, in Vite and
classic builds alike.
npm i real-enums @real-enums/warp-drive
// app/services/store.ts
import { useRecommendedStore } from '@warp-drive/core'
import { JSONAPICache } from '@warp-drive/json-api'
import { enumTransformation } from '@real-enums/warp-drive'
export default useRecommendedStore({
cache: JSONAPICache,
schemas: [OrderSchema],
transformations: [enumTransformation(OrderState, 'order-state')],
})
// app/schemas/order.ts
export const OrderSchema = withDefaults({
type: 'order',
fields: [
{ kind: 'field', name: 'customer' },
{ kind: 'field', name: 'state', type: 'order-state' },
],
})
// app/transforms/order-state.ts
import { enumTransform } from '@real-enums/warp-drive'
export default enumTransform(OrderState)
// app/models/order.ts
import Model, { attr } from '@warp-drive/legacy/model'
export default class Order extends Model {
@attr declare customer: string
@attr('order-state') declare state: OrderState
}
Legacy transforms run in the serializer, so load through an adapter or
store.pushPayload; a raw store.push skips it.
In a component
import { OrderState } from 'my-app/enums/order-state';
<template>
<select {{on "change" @onFilter}}>
<option value="">All</option>
{{#each OrderState.values as |state|}}
<option value={{state.value}}>{{state.label}}</option>
{{/each}}
</select>
{{#each @orders as |order|}}
<li class={{if order.state.isCancelled "muted"}}>
<span class={{order.state.className}}>{{order.state.label}}</span>
</li>
{{/each}}
</template>
React
Whatever fetches your data
Each adapter is a few lines over one shared mapper, tested against the real library: a
real QueryClient, a real Redux store, a real InMemoryCache, a
real Graphcache.
TanStack Query
npm i real-enums @real-enums/tanstack-query
import { enumSelect } from '@real-enums/tanstack-query'
// Outside the component, so it isn't re-run every render.
const selectOrders = enumSelect<Order[]>({ state: OrderState, 'lines.status': LineStatus })
const { data } = useQuery({ queryKey: ['orders'], queryFn: fetchOrders, select: selectOrders })
Mapping happens in select, so the cache, dehydrate and
persisters keep plain JSON, and structural sharing keeps the result stable across
identical refetches.
SWR
npm i real-enums @real-enums/swr
import { withEnums } from '@real-enums/swr'
const fetchOrders = withEnums<Order[]>(fetchJson, { state: OrderState })
const { data } = useSWR('/api/orders', fetchOrders) // data: Order[] | undefined
RTK Query
npm i real-enums @real-enums/rtk-query
import { enumResponse, isSerializable } from '@real-enums/rtk-query'
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: build => ({
orders: build.query<Order[], void>({
query: () => '/orders',
transformResponse: enumResponse<Order[]>({ state: OrderState }),
}),
}),
})
const store = configureStore({
reducer: { [api.reducerPath]: api.reducer },
// Without this, the dev-mode check reports every enum as non-serializable.
middleware: getDefault =>
getDefault({ serializableCheck: { isSerializable } }).concat(api.middleware),
})
Apollo Client
npm i real-enums @real-enums/apollo
import { enumField } from '@real-enums/apollo'
const cache = new InMemoryCache({
typePolicies: { Order: { fields: { state: enumField(OrderState) } } },
})
Reads return enums; the normalized cache keeps the raw strings, however they were written.
urql (Graphcache)
npm i real-enums @real-enums/urql
import { enumResolver } from '@real-enums/urql'
cacheExchange({ resolvers: { Order: { state: enumResolver(OrderState) } } })
Anything else
import { mapFields, realEnum } from 'real-enums'
// 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.
const { parse, serialize } = OrderState.codec()
// Strict validation for anything that takes a Standard Schema.
const schema = realEnum(OrderState)
Playground
Try it live
The same orders screen in six apps, each open on StackBlitz in your browser. Only the data wiring differs: the badge, the button copy and the filter all come from the enum. Every app's test suite runs in CI.
| Data layer | Framework | Open |
|---|---|---|
| WarpDrive schema records | Ember, Vite | StackBlitz |
| ember-data Models | Ember, Vite | StackBlitz |
| TanStack Query | React | StackBlitz |
| SWR | React | StackBlitz |
| RTK Query | React | StackBlitz |
| Apollo Client | React | StackBlitz |
Reference
The whole API
| Member | What it does |
|---|---|
value, label, id |
The raw string; it capitalized (override it); the instance itself, for option
values in templates (in JSX, use value).
|
Cls.values |
Every static instance, in declaration order. |
Cls.valueFor(raw) |
The instance for a raw value, or undefined. Takes an instance too.
|
@is(), @isNot() |
Predicates named after a static member. A name with no matching member throws on first read. |
Cls.codec() |
{ parse, serialize }: lenient parsing (unknown and missing become
undefined), raw values pass through serialize.
|
mapFields(data, map) |
A copy of data with dotted paths mapped to instances. Never mutates;
safe to run twice.
|
realEnum(Cls) |
A strict Standard Schema v1 validator. |
Full details in the package README, including upgrading from 1.x.