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

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.

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:

  1. Declare members with Cls.of('value'), never new.
  2. When every member needs an answer, use Cls.match(value) and end with .exhaustive(). Not if/else, not switch, not a lookup table. Tables keyed by value are for lookups only some members answer, like next.
  3. Cases are static members (OrderState.Pending), never strings ('pending').
  4. Use .otherwise() only when a fallback is truly intended; it turns the check off.
  5. Compare members with === or a predicate like isPending, not with their string value.
  6. Fix a match type 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.