Enums for TypeScript, Ember and React

Your API sends strings. Your UI gets real enums.

Enums that are classes: the label, the badge colour and what comes next live on the value, not in an if ladder in every template. Adapters turn the strings your server sends into enum instances, for WarpDrive and ember-data, TanStack Query, SWR, RTK Query, Apollo and urql, and back into strings on the way out.

npm i real-enums

Then add the adapter for your data layer, if you have one.

Raw strings from the server become OrderState instances, through an adapter for each data layer, and render as a labelled badge with the next action. On the wire Your data layer "pending" "shipped" "delivered" WarpDrive ember-data TanStack Query SWR RTK Query Apollo Client urql class OrderState extends Enum { static Pending = … static Shipped = … static Delivered = … get next () { … } get className () { … } } Your template Shipped Deliver

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.

Before: the ladder, everywhere the state appears
{{#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}}
After: one line, whatever the state
<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
Schema records
// 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' },
  ],
})
Legacy Models
// 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.