Type-safe enum creation for TypeScript and Zod — stop duplicating your enums.
zenums turns a tuple of string literals into a small, frozen enum-like object:
values— the original tuple (single source of truth)constants— CONSTANT_CASE keysnames— PascalCase keysis(value)— type guardparse(value)— runtime parser for a single value (throwsZenumsError)withValues(fn)— runsfn(values)without copying
It also supports optional Zod integration via a small subpath export.
Use zenums when you want one tuple to remain the single source of truth for:
- literal union types
- runtime enum-like access
- stable generated keys
- Zod schemas without redefining values
npm i zenums
# optional, only if you use zenums/zod
npm i zodimport { createEnum } from 'zenums'
const Transport = createEnum(['stdout', 'stderr', 'API2'] as const)
type TransportValue = (typeof Transport.values)[number]
// 'stdout' | 'stderr' | 'API2'
// tuple values (single source of truth, preserved as authored)
Transport.values
// => ['stdout', 'stderr', 'API2']
// constants + names
Transport.constants.STDOUT // 'stdout'
Transport.names.Stdout // 'stdout'
// type guard
if (Transport.is('stdout')) {
// 'stdout' is narrowed to the literal union
}
// parser (throws ZenumsError, code: 'invalidValue')
Transport.parse('nope')zenums derives two stable key spaces from your string values:
constants:SCREAMING_SNAKE_CASEkeys for safe, ergonomic importsnames:PascalCasekeys for “nice” programmatic access
import { createEnum } from 'zenums'
const Transport = createEnum(['foo-bar', 'stdout', 'API2'] as const)
// values stay exactly as authored (order preserved)
Transport.values // ['foo-bar', 'stdout', 'API2']
// constants: uppercase, separators normalized to underscore
Transport.constants.FOO_BAR // 'foo-bar'
Transport.constants.STDOUT // 'stdout'
Transport.constants.API2 // 'API2'
// names: PascalCase (separators are word breaks)
Transport.names.FooBar // 'foo-bar'
Transport.names.Stdout // 'stdout'
Transport.names.API2 // 'API2'Some different inputs can generate the same keys after normalization:
createEnum(['foo-bar', 'foo_bar'] as const)
// ❌ throws: collision (both produce FOO_BAR / FooBar)Other “edge” but valid examples:
const ValidExample = createEnum(['r2d2', 'api2', 'my_value'] as const)
ValidExample.constants.R2D2 // 'r2d2'
ValidExample.names.R2d2 // 'r2d2'
ValidExample.constants.API2 // 'api2'
ValidExample.names.Api2 // 'api2'
ValidExample.constants.MY_VALUE // 'my_value'
ValidExample.names.MyValue // 'my_value'If you need the generated keys for debugging, you can call toConstKey(value) / toNameKey(value) directly.
createEnum() validates values before generating keys.
Rules are enforced to keep generated keys deterministic, readable, and collision-safe.
Summary:
- Array shape: non-empty array
- Type: each item must be a string
- Length: at least 2 characters
- Allowed chars:
A–Z,a–z,0–9,-,_ - Separators: either
-or_(not both), no leading/trailing separators, no double separators (--,__) - Digits: must not start with a digit, must not be numeric-only (even with separators like
1-2) - Meaningful: must contain at least one letter
- CAPS tokens:
ALL_CAPSwithout digits is rejected, butAPI2/R2D2are allowed - Duplicates: exact duplicate strings are rejected (no normalization)
When multiple issues exist, createEnum() throws a ZenumsError with code definitionRejected and a deterministic report.
Requires zod to be installed in the consumer project.
If you use Zod, zenums/zod provides a thin wrapper over z.enum() that preserves tuple literal types.
Return type is inferred for Zod v3 / v4 compatibility.
import * as z from 'zod'
import { createEnum } from 'zenums'
import { toZodEnum } from 'zenums/zod'
const Transport = createEnum(['stdout', 'stderr'] as const)
const Schema = toZodEnum(z, Transport.values)
Schema.parse('stdout') // ok
Schema.safeParse('nope').success // falseYou can also skip the wrapper and use Zod directly:
import * as z from 'zod'
import { createEnum } from 'zenums'
const VALUES = ['stdout', 'stderr'] as const
const Transport = createEnum(VALUES)
const SchemaA = z.enum(Transport.values) // recommended
const SchemaB = z.nativeEnum(Transport.constants) // optionalIn general, z.enum(Transport.values) is the most predictable for string-literal unions and error messages.
Keep your tuple as the single source of truth and reuse it for both createEnum() and z.enum().
import * as z from 'zod'
import { createEnum } from 'zenums'
const VALUES = ['stdout', 'stderr'] as const // 1) source tuple
const Status = createEnum(VALUES) // 2) runtime utilities
const StatusSchema = z.enum(Status.values) // 3) validation schema, same tuple reusedWhen multiple issues exist, createEnum() throws a ZenumsError with code definitionRejected and a deterministic report.
import { createEnum } from 'zenums'
createEnum(['foo', 'foo', 'foo-bar', 'foo_bar', 'a'] as const)Example output (formatted for logs and snapshots):
ZenumsError: Enum definition rejected.
Stats:
received: 5
valid: 2
invalid: 1
duplicates: 1
collisions: 2 (constants: 1, names: 1)
Details:
Invalid:
• [4] "a" — tooShort: minimum length is 2
Duplicates:
• [0, 1] "foo" — duplicate
Collisions (constants):
• "FOO_BAR" — collision (sources):
• "foo-bar"
• "foo_bar"
Collisions (names):
• "FooBar" — collision (sources):
• "foo-bar"
• "foo_bar"
zenums is runtime-agnostic and works in both Node.js (>=20) and Bun.
Note: the project uses Bun for development/CI (bun test, bun run build), but the published package is plain ESM/CJS and does not require Bun at runtime.
import { createEnum, toConstKey, toNameKey, ZenumsError } from 'zenums'
import { toZodEnum } from 'zenums/zod'MIT