Skip to content

Validate String Literal Types with Zod

Problem Statement

When working with TypeScript, you may define specific string literal types like:

ts
type PaymentType = 'CHECK' | 'DIRECT DEPOSIT' | 'MONEY ORDER';

However, runtime validation of these types presents challenges. When receiving input from external sources (like APIs or user input), you need to verify that strings match one of these pre-defined options. Using Zod's generic z.string() validator won't ensure that the string matches one of your exact literal values.

The most maintainable solution uses Zod's z.enum combined with TypeScript's as const assertion. This creates a single source of truth for both runtime validation and type inference:

ts
import { z } from 'zod';

// Define values with 'as const' for tuple inference
const PAYMENT_TYPES = ['CHECK', 'DIRECT DEPOSIT', 'MONEY ORDER'] as const;

// Create Zod schema
const PaymentTypeSchema = z.enum(PAYMENT_TYPES);

// Infer TypeScript type from schema
type PaymentType = z.infer<typeof PaymentTypeSchema>;

// Use in your schema definition
const schema = z.object({
  paymentType: PaymentTypeSchema,
});

// Valid usage
schema.parse({ paymentType: 'CHECK' }); // ✓ Passes

// Invalid usage
schema.parse({ paymentType: 'CASH' }); 
// ❌ Throws error: "Invalid enum value. Expected 'CHECK' | 'DIRECT DEPOSIT' | 'MONEY ORDER'"

Key Advantages

  1. Single Source of Truth: The PAYMENT_TYPES array defines values once for both runtime and types
  2. Type Safety: as const preserves literal types (instead of converting to string[])
  3. DRY Principle: Avoids duplicating value definitions
  4. Type Inference: z.infer automatically aligns your types with Zod's validation
  5. Extensibility: Add/remove values in one location

TIP

Use this method when you need to co-locate runtime values with TypeScript types without duplication

Alternative Approaches

Union Type with z.union

For more control or custom error messages per literal:

ts
const PaymentTypeSchema = z.union([
  z.literal('CHECK'),
  z.literal('DIRECT DEPOSIT'),
  z.literal('MONEY ORDER'),
]);

Note

This becomes verbose quickly for more than 3-4 options. Prefer z.enum for simple cases

Native TypeScript Enums

If you prefer traditional enums:

ts
enum PaymentType {
  Check = 'CHECK',
  DirectDeposit = 'DIRECT DEPOSIT',
  MoneyOrder = 'MONEY ORDER'
}

const PaymentTypeSchema = z.nativeEnum(PaymentType);

Enum Limitations

TypeScript enums have several drawbacks:

  • Create runtime code in compiled JS
  • Less flexible with dynamic operations
  • Mix numeric/string values can cause confusion

When using z.nativeEnum, avoid numerical enums as they behave differently at runtime.

Implementation Best Practices

1. Defining Literal Arrays Properly

Always use as const with your literal arrays:

ts
// Correct
const COLORS = ['RED', 'GREEN', 'BLUE'] as const; 

// Incorrect (loses literal type information)
const COLORS = ['RED', 'GREEN', 'BLUE'];

2. Exporting Schemas and Types

Organize your schemas for reuse:

ts
// schemas.ts
export const PAYMENT_TYPES = [...] as const;
export const PaymentTypeSchema = z.enum(PAYMENT_TYPES);
export type PaymentType = z.infer<typeof PaymentTypeSchema>;

// usage.ts
import { PaymentTypeSchema, type PaymentType } from './schemas';

3. Handling Null/Undefined

For optional values, combine with .optional() or .nullable():

ts
const schema = z.object({
  paymentType: PaymentTypeSchema.optional(),
});

Common Errors and Solutions

"Type 'string' is not assignable to type..."

Problem: Assigning strings to functions expecting specific literals

ts
const setPayment = (type: PaymentType) => {}

// ❌ Error: string is not assignable to PaymentType
const payment = 'CHECK'; 
setPayment(payment);

Solution: Validate strings with your schema:

ts
// Before use, parse the value
const getPaymentType = (input: string) => {
  return PaymentTypeSchema.parse(input); // Returns PaymentType
}

Enum Schemas Changing at Runtime

Problem: Arrays without as const widen to string[]

ts
const PAYMENTS = ['CHECK', 'DIRECT DEPOSIT']; // Inferred as string[]
PAYMENTS.push('INVALID'); // 💥 Runtime array corruption

Solution: Use readonly arrays with as const:

ts
const PAYMENTS = ['CHECK', 'DIRECT DEPOSIT'] as const;
// ❌ Now errors at compile-time if modified:
PAYMENTS.push('INVALID');

Choosing the Right Approach

MethodBest ForWhen to Avoid
z.enum + as constMost common casesComplex validation logic
z.union + z.literalCustom per-value logicLarge option sets (>5 values)
z.nativeEnumLegacy code with existing enumsNew projects, dynamic use cases

For most applications, the z.enum with as const approach provides the best combination of type safety, runtime validation, and maintainability.