| | |

Zod 12 🛡️ Enums and Literals — z.enum() and z.literal() in v4

⏱️ Estimated read: 9 min | |

Zod provides two schemas for constraining a value to a fixed set: z.literal() for a single exact value and z.enum() for a set of string values. In Zod v4, the two constructors changed in ways that affect migration from v3. z.literal() dropped symbol support, and z.enum() absorbed the functionality of the deprecated z.nativeEnum(), now accepting TypeScript enums directly . The inferred types remain precise literal unions, which is what makes these schemas useful for discriminated unions and exhaustive switches.

This chapter covers the literal schema, the unified z.enum() constructor, and the v4 changes that determine which syntax is current. It explains why z.enum() is preferred over a union of literals for string sets, how the .enum property exposes the values, and what the z.nativeEnum() deprecation means for existing code. The sections that follow walk through literals, enums, and the migration patterns.

Key point: z.literal(v) accepts exactly one value; z.enum([...]) accepts a set of string literals and, in v4, also accepts TypeScript enums directly—z.nativeEnum() is deprecated.


Why literals and enums exist

The plain-string problem. A schema that uses z.string() for a status field accepts every string, including typos. z.enum(["pending", "shipped"]) accepts only the two valid values, and the inferred type is the literal union "pending" | "shipped". The compiler and the validator agree on the set, which is the point of using the schema instead of a bare string .

The single-value problem. Some fields are not a set but a single fixed value: a discriminator that is always "success", a version that is always 1, a type that is always "customer". z.literal("success") accepts only that value and infers the literal type. This is the schema used inside discriminated unions, where each branch has a distinct literal that the parser reads to select the branch .

The native-enum problem. Zod v3 had two constructors: z.enum() for string arrays and z.nativeEnum() for TypeScript/JavaScript enums. Zod v4 unified them. z.enum() now accepts a string array, a TypeScript enum (string or numeric), or an enum-like object. z.nativeEnum() still runs but is deprecated, and its type definition marks it as such . A codebase migrating to v4 should replace every z.nativeEnum(E) with z.enum(E).


a. z.literal() and the v4 changes

z.literal(value) creates a schema that accepts exactly one value. The inferred type is the literal type, not the broader primitive type.

import { z } from "zod";

const SuccessSchema = z.literal("success"); // "success"
const VersionSchema = z.literal(1);         // 1
const TrueSchema = z.literal(true);         // true

The v4 changes to z.literal() are narrow. Symbol support was removed because symbols are not literal values in the sense the constructor implies and cannot be compared with === in a type-safe way . The null and undefined values were already not supported in v3 and remain unsupported in v4; z.null() and z.undefined() are the correct schemas for those.

A literal is rarely used alone. Its most important role is inside a discriminated union, where it identifies the branch. The parser reads the literal field, finds the matching branch, and validates only that branch.

const ResponseSchema = z.discriminatedUnion("status", [
  z.object({ status: z.literal("success"), data: z.unknown() }),
  z.object({ status: z.literal("error"), message: z.string() }),
]);

b. z.enum() and the unified constructor

z.enum() accepts a set of values and infers the union of those values as literal types. In v4, it accepts three forms: a string array, a TypeScript enum, and an enum-like object.

// String array (the common case)
const StatusSchema = z.enum(["pending", "processing", "shipped"]);
// "pending" | "processing" | "shipped"

// TypeScript enum (v4 unified; was z.nativeEnum in v3)
enum Role {
  Admin = "admin",
  User = "user",
}
const RoleSchema = z.enum(Role);
// "admin" | "user"

The .enum property exposes the values as an object, and .options returns the values as an array. In v3, .Enum and .Values served these purposes; both are removed in v4, and .enum is the replacement . The exclude and extract methods derive new schemas from an existing enum schema.

StatusSchema.enum.pending;          // "pending"
StatusSchema.options;               // ["pending", "processing", "shipped"]
const OpenStatus = StatusSchema.exclude(["shipped"]); // pending | processing

The v4.6 change to numeric enums is worth noting. For a numeric TypeScript enum like enum Country { UK, Germany }, z.enum(Country).options now lists only the values ([0, 1]), without the reverse-mapping keys that TypeScript generates for numeric enums . This is a correctness fix: the reverse mappings are not valid enum values, and listing them as options was misleading.


c. Literals versus enums and the migration

The choice between z.literal() and z.enum() is determined by the size of the set. A single value uses z.literal(). A set of two or more string values uses z.enum(), which is more direct than a union of literals and produces the same inferred type .

// Preferred for a set
const SizeSchema = z.enum(["small", "medium", "large"]);

// Equivalent but more verbose
const SizeUnion = z.union([
  z.literal("small"),
  z.literal("medium"),
  z.literal("large"),
]);

The migration from v3 to v4 is mechanical for enums. Replace z.nativeEnum(E) with z.enum(E). Replace .Enum with .enum. Replace .Values with .options. The ESLint rule zod/no-native-enum automates the first replacement when the schema is accessed through the z namespace .

The migration for literals is smaller. No changes are required unless the code used z.literal(Symbol()), which was removed. The null and undefined cases, if they existed, were already using z.null() and z.undefined().


Complete Example Session

// ============================================
// PART 1: LITERALS
// ============================================
import { z } from "zod";

const Success = z.literal("success");
const Version = z.literal(1);

Success.parse("success"); // valid
Success.parse("error");   // ZodError

// ============================================
// PART 2: STRING ARRAY ENUM
// ============================================
const Status = z.enum(["pending", "shipped"]);
Status.parse("pending"); // valid
Status.parse("unknown"); // ZodError

// ============================================
// PART 3: TYPESCRIPT ENUM (v4 UNIFIED)
// ============================================
enum Role {
  Admin = "admin",
  User = "user",
}
const RoleSchema = z.enum(Role); // was z.nativeEnum(Role) in v3
RoleSchema.parse("admin"); // valid

// ============================================
// PART 4: THE .enum PROPERTY
// ============================================
Status.enum.pending;    // "pending"
Status.options;         // ["pending", "shipped"]

// ============================================
// PART 5: DERIVED SCHEMAS
// ============================================
const OpenStatus = Status.exclude(["shipped"]); // "pending"

// ============================================
// PART 6: DISCRIMINATED UNION WITH LITERALS
// ============================================
const Event = z.discriminatedUnion("type", [
  z.object({ type: z.literal("created"), id: z.string() }),
  z.object({ type: z.literal("deleted"), id: z.string() }),
]);

The parts covered literals, string-array enums, TypeScript enums through the unified constructor, the .enum property, derived schemas, and the discriminated union that uses literals.


Quick Reference

Constructors

SchemaAcceptsInferred Type
z.literal(v)Exactly vv
z.enum([...])One of the listed stringsUnion of literals
z.enum(TSEnum)One of the enum valuesUnion of enum values

v3 to v4 Migration

v3 Syntaxv4 Syntax
z.nativeEnum(E)z.enum(E) (deprecated in v4)
.Enum.enum
.Values.options

Properties and Methods

Property/MethodReturns
.enumObject of values
.optionsArray of values
.exclude([...])Schema without listed values
.extract([...])Schema with only listed values

Best Practices

✅ Do This:

// Use literal for a single fixed value
z.literal("success")                                         // ✅

// Use enum for a set of strings
z.enum(["pending", "shipped"])                               // ✅

// Use the unified constructor for TS enums
z.enum(Role)                                                 // ✅

// Access values through .enum
Status.enum.pending                                          // ✅

❌ Don’t Do This:

// Use z.nativeEnum (deprecated)
z.nativeEnum(Role)                                           // ❌

// Use a plain string for a fixed set
z.string() // accepts typos                                  // ❌

// Use a union of literals for a set
z.union([z.literal("a"), z.literal("b")])                    // ❌ (use z.enum)

// Expect .Enum or .Values
Status.Enum // removed in v4                                 // ❌

Common Pitfalls

PitfallWhy It HappensFix
z.nativeEnum not a functionRemoved in v4Use z.enum(E)
.Enum undefinedRemoved in v4Use .enum
.Values undefinedRemoved in v4Use .options
Numeric enum options include keysFixed in v4.6Upgrade

Real-World Examples

1. Single literal

z.literal("success");

2. Numeric literal

z.literal(200);

3. String enum

z.enum(["small", "medium", "large"]);

4. TypeScript enum (v4)

z.enum(Role);

5. Access values

Status.enum.pending;

6. Options array

Status.options;

7. Exclude a value

Status.exclude(["shipped"]);

8. Literal in discriminated union

type: z.literal("created");

9. ESLint autofix

zod/no-native-enum converts z.nativeEnum to z.enum

10. Numeric enum options (v4.6)

// z.enum(Country).options === [0, 1]

Visual

Literal vs Enum

┌─────────────────────────────────────────────────────────┐
│  z.literal("success")                                   │
│  ┌──────────────────────────────────────────┐           │
│  │ Accepts: "success" only                  │           │
│  │ Type: "success"                          │           │
│  └──────────────────────────────────────────┘           │
│                                                         │
│  z.enum(["pending", "shipped"])                         │
│  ┌──────────────────────────────────────────┐           │
│  │ Accepts: "pending" or "shipped"          │           │
│  │ Type: "pending" | "shipped"              │           │
│  └──────────────────────────────────────────┘           │
└─────────────────────────────────────────────────────────┘

v4 Enum Unification

┌─────────────────────────────────────────────────────────┐
│  v3                          v4                         │
│                                                         │
│  z.enum(["a", "b"])          z.enum(["a", "b"])         │
│  z.nativeEnum(MyEnum)        z.enum(MyEnum)             │
│                              (nativeEnum deprecated)    │
│                                                         │
│  .Enum                       .enum                      │
│  .Values                     .options                   │
└─────────────────────────────────────────────────────────┘

Enum Properties

┌─────────────────────────────────────────────────────────┐
│  const Status = z.enum(["pending", "shipped"])          │
│                                                         │
│  Status.enum      → { pending: "pending",               │
│                       shipped: "shipped" }              │
│  Status.options   → ["pending", "shipped"]              │
│  Status.exclude(["shipped"]) → z.enum(["pending"])      │
└─────────────────────────────────────────────────────────┘

Literal in Discriminated Union

┌─────────────────────────────────────────────────────────┐
│  type: z.literal("created")                             │
│  type: z.literal("deleted")                             │
│                                                         │
│  z.discriminatedUnion("type", [...])                    │
│         │                                               │
│         ▼                                               │
│  Parser reads "type", dispatches to matching branch     │
│                                                         │
│  The literal is the dispatch key                        │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Single valuez.literal(v)
String setz.enum([...])
TypeScript enum (v4)z.enum(TSEnum)
Deprecatedz.nativeEnum()
Values object.enum
Values array.options
Derived schema.exclude() / .extract()
v4 literal changesymbol support removed

Key takeaways:

  • Literals are for single values; enums are for sets. z.literal("success") accepts one exact value. z.enum([...]) accepts a set of strings and infers the union of those literals .
  • v4 unified z.enum(). It now accepts TypeScript enums directly, and z.nativeEnum() is deprecated. The migration is z.nativeEnum(E) → z.enum(E) .
  • The .enum and .options properties replaced .Enum and .Values. .enum returns the values as an object; .options returns them as an array. Both v3 properties are removed .
  • z.literal() dropped symbol support in v4. Symbols are not literal values in the sense the constructor implies. Null and undefined were already unsupported; use z.null() and z.undefined() .
  • Enums are the correct schema for fixed string sets. A plain z.string() accepts typos; z.enum() catches them at the boundary and gives the compiler the literal union to narrow on .

Remember: A literal is a single value; an enum is a set. Use z.literal() for discriminators and constants, and z.enum() for any field that should accept one of several named values. In v4, the enum constructor is unified: it takes string arrays, TypeScript enums, and enum-like objects. If the code still calls z.nativeEnum(), it is calling a deprecated function that will eventually be removed. The migration is mechanical, and the ESLint rule can automate it.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!