ts-safe-enum
v2.0.0
Published
A tiny, type-safe alternative to TypeScript enums. Direct member access, runtime validation, type guards, and zero dependencies.
Maintainers
Readme
ts-safe-enum
A tiny, type-safe alternative to TypeScript enums. Plain objects, full inference, runtime validation — and direct member access that feels just like a native enum.
import { defineEnum, type InferValue } from 'ts-safe-enum';
const Status = defineEnum({
Active: 'active',
Inactive: 'inactive',
Pending: 'pending',
});
Status.Active // 'active' ← direct access
Status.is(input) // type guard
Status.assert(input) // throws on invalid
Status.parse(input) // { ok, value } | { ok, error }
[...Status] // ['active', 'inactive', 'pending']
type Status = InferValue<typeof Status>;
// 'active' | 'inactive' | 'pending'Why?
TypeScript's built-in enum looks simple but hides real problems:
- Numeric enums generate reverse mappings and accept any number at runtime
- String enums behave like nominal types — they reject identical strings from APIs and
localStorage const enumgets inlined away and breaks with declaration files, barrel exports, and isolated modules- All enums compile to IIFEs that bundlers can't tree-shake
The community alternative is as const objects with union types, but you lose type guards, validation, reverse lookup, and ergonomic member access. You end up writing boilerplate for every enum.
ts-safe-enum gives you a single function that turns a plain object into a fully typed enum with everything you need — zero dependencies, ~2KB minified, frozen by default, and .parse() never throws on any input.
Read the full philosophy: Why I Stopped Using Enums in TypeScript
Install
npm install ts-safe-enum
# or
pnpm add ts-safe-enum
# or
yarn add ts-safe-enum
# or
bun add ts-safe-enumQuick Start
import { defineEnum, type InferValue } from 'ts-safe-enum';
// Object form — explicit key/value mapping:
const Status = defineEnum({
Active: 'active',
Inactive: 'inactive',
Pending: 'pending',
});
Status.Active // 'active' — typed as the literal 'active'
Status.size // 3
type Status = InferValue<typeof Status>;
// 'active' | 'inactive' | 'pending'
// Array form — shorthand when key === value:
const Direction = defineEnum(['Up', 'Down', 'Left', 'Right']);
Direction.Up // 'Up'
type Direction = InferValue<typeof Direction>;
// 'Up' | 'Down' | 'Left' | 'Right'API
defineEnum(definition) — Create a type-safe enum
Accepts a plain object or an array of strings. Literal types are inferred automatically — no as const needed.
const HttpStatus = defineEnum({
Ok: 200,
NotFound: 404,
ServerError: 500,
});
HttpStatus.Ok // 200
HttpStatus.NotFound // 404
const Role = defineEnum(['Admin', 'Editor', 'Viewer']);
Role.Admin // 'Admin'Direct member access
The enum instance exposes each member as a property. This is the same shape you'd expect from a native enum, with full literal-type inference.
Status.Active // 'active'
Status.Pending // 'pending'
// Use it anywhere — switch cases, comparisons, default values:
function describe(s: InferValue<typeof Status>) {
switch (s) {
case Status.Active: return 'on';
case Status.Inactive: return 'off';
case Status.Pending: return 'wait';
}
}Reserved keys: the following names are reserved and cannot be used as enum members:
definition,size,values,keys,entries,is,assert,parse,keyOf.defineEnumthrows a clear error at construction time if you use one.Duplicate values are rejected. Both
defineEnum({ A: 'x', B: 'x' })(object form) anddefineEnum(['Up', 'Up'])(array form) throw at construction time. Each enum value must be unique so reverse lookup (keyOf) and iteration stay consistent.
.size — Number of members
Status.size // 3.values() — All values as a frozen array
Status.values();
// readonly ['active', 'inactive', 'pending'].keys() — All keys as a frozen array
Status.keys();
// readonly ['Active', 'Inactive', 'Pending'].entries() — All [key, value] pairs
Each entry preserves its specific key/value correlation in the type — narrowing on the key narrows the value.
Status.entries();
// readonly (['Active', 'active'] | ['Inactive', 'inactive'] | ['Pending', 'pending'])[]
for (const entry of Status.entries()) {
switch (entry[0]) {
case 'Active':
// entry[1] is narrowed to 'active'
break;
}
}Iteration
The enum is iterable over its values:
for (const value of Status) {
console.log(value); // 'active', 'inactive', 'pending'
}
[...Status] // ['active', 'inactive', 'pending']
Array.from(Status) // same.is(value) — Type guard
Narrows unknown to the enum's value union:
const input: unknown = getUserInput();
if (Status.is(input)) {
// input is narrowed to 'active' | 'inactive' | 'pending'
console.log(`Valid status: ${input}`);
}.assert(value) — Throw on invalid
Use when you expect the value to be valid and want to fail loudly. Throws a TypeError with a descriptive message and narrows the type after the call. Safe on any input — including BigInt, Symbol, functions, and circular objects.
function load(input: unknown) {
Status.assert(input);
// input is now narrowed to 'active' | 'inactive' | 'pending'
return fetchByStatus(input);
}.parse(value) — Validate with a Result
Returns { ok: true, value } or { ok: false, error }. .parse() never throws — it's safe to call on any value, including BigInt, Symbol, functions, and objects with circular references. The error is structured so you can build your own messages or report it programmatically.
const result = Status.parse(apiResponse.status);
if (result.ok) {
console.log(result.value); // typed as 'active' | 'inactive' | 'pending'
} else {
console.error(result.error.message);
// 'Invalid enum value: "unknown". Expected one of: "active", "inactive", "pending".'
result.error.received; // the value you passed in
result.error.expected; // readonly ['active', 'inactive', 'pending']
}The returned result and its error object are both frozen — they're safe to share across modules without defensive copies.
.keyOf(value) — Reverse lookup
Get the key name for a value. Accepts unknown, so you can pass untrusted input directly without casts.
Status.keyOf('active') // 'Active'
Status.keyOf('inactive') // 'Inactive'
Status.keyOf('???') // undefined (no cast needed)Type Helpers
InferValue<T> — Extract the value union
type Status = InferValue<typeof Status>;
// 'active' | 'inactive' | 'pending'
function setStatus(status: Status) { /* ... */ }
setStatus(Status.Active); // ✅
setStatus('unknown'); // ❌ type errorInferKey<T> — Extract the key union
type StatusKey = InferKey<typeof Status>;
// 'Active' | 'Inactive' | 'Pending'Real-World Patterns
Validating API responses
const Role = defineEnum({
Admin: 'admin',
Editor: 'editor',
Viewer: 'viewer',
});
type Role = InferValue<typeof Role>;
function handleUser(apiData: { role: string }) {
const result = Role.parse(apiData.role);
if (!result.ok) {
throw new Error(`Unknown role: ${result.error.received}`);
}
const role = result.value; // typed as 'admin' | 'editor' | 'viewer'
}Exhaustive switch statements
const Theme = defineEnum({
Light: 'light',
Dark: 'dark',
System: 'system',
});
type Theme = InferValue<typeof Theme>;
function getBackground(theme: Theme): string {
switch (theme) {
case Theme.Light: return '#ffffff';
case Theme.Dark: return '#1a1a1a';
case Theme.System: return getSystemBackground();
}
}Iterating for UI rendering
const Priority = defineEnum({
Low: 1,
Medium: 2,
High: 3,
Critical: 4,
});
// Render a dropdown
Priority.entries().map(([label, value]) => (
<option key={value} value={value}>{label}</option>
));Discriminated-union pattern
.parse() returns a tagged result that narrows naturally with a single if:
const Color = defineEnum({
Red: 'red',
Green: 'green',
Blue: 'blue',
});
const result = Color.parse(userInput);
if (result.ok) {
applyColor(result.value); // typed as 'red' | 'green' | 'blue'
} else {
showError(result.error.message);
}The shape ({ ok: true, value } | { ok: false, error }) interoperates cleanly with ts-safe-result — wrap the parts in ok() / err() if you want chaining helpers like .map() and .match().
Comparison
| Feature | ts-safe-enum | TS enum | as const + manual | ts-enum-util |
| --- | --- | --- | --- | --- |
| Tree-shakable | Yes | No (IIFEs) | Yes | Partial |
| Direct member access | Yes | Yes | Yes | Partial |
| No reverse mapping | Yes | No (numeric) | Yes | No |
| Type guard | Yes | No | Manual | Yes |
| Validation w/ Result | Yes | No | Manual | No |
| Throwing assert | Yes | No | Manual | No |
| Reverse lookup | Yes | Numeric only | Manual | Yes |
| Iterable | Yes | No | Manual | Partial |
| as const required | No | N/A | Yes | N/A |
| Bundle size | ~2KB | ~0 (inlined) | 0 | ~3KB |
Migrating from v1
v2 adds direct member access, .assert(), iteration, .size, and a structured parse() error.
Direct access — recommended:
// v1
case Status.definition.Active: ...
// v2 — much cleaner
case Status.Active: ...parse() error is now an object, not a string:
// v1
if (!result.ok) console.error(result.error);
// v2
if (!result.ok) console.error(result.error.message);
// You can also access result.error.received and result.error.expectedkeyOf() no longer requires a cast for untrusted input:
// v1
Status.keyOf(input as never);
// v2
Status.keyOf(input);Reserved keys: if any of definition, size, values, keys, entries, is, assert, parse, keyOf are used as member names, defineEnum throws at construction time. Rename the offending key.
Duplicate values now throw. v1 silently allowed { A: 'x', B: 'x' } and defineEnum(['Up', 'Up']), which produced inconsistent state (lossy reverse lookup, misleading size). v2 rejects both at construction time. If you relied on aliasing, define the value once and reference it through both names yourself.
parse() and assert() are now safe on any input. v1's error formatting could throw TypeError for BigInt, circular objects, etc. v2 uses a defensive stringifier — .parse() is guaranteed never to throw, and .assert() always throws our TypeError with a useful message.
Part of the ts-safe family
ts-safe-result— Type-safeResult<Value, Error>for TypeScriptts-safe-enum— Type-safe enum alternative (you are here)
Philosophy
- Plain objects over magic. An enum is just an object that TypeScript understands completely — no code generation, no IIFEs, no reverse mappings.
- Inference over annotation. You shouldn't need
as const, type aliases, or helper types just to define a set of constants. - Validate at boundaries. APIs send strings,
localStoragestores strings, URLs contain strings..is(),.assert(), and.parse()handle the real world. - Frozen by default. Enum instances and the arrays they return are immutable at runtime, not just in the type system.
License
MIT
