Validate String Literal Types with Zod
Problem Statement
When working with TypeScript, you may define specific string literal types like:
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.
Recommended Solution: z.enum
with as const
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:
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
- Single Source of Truth: The
PAYMENT_TYPES
array defines values once for both runtime and types - Type Safety:
as const
preserves literal types (instead of converting tostring[]
) - DRY Principle: Avoids duplicating value definitions
- Type Inference:
z.infer
automatically aligns your types with Zod's validation - 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:
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:
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:
// 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:
// 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()
:
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
const setPayment = (type: PaymentType) => {}
// ❌ Error: string is not assignable to PaymentType
const payment = 'CHECK';
setPayment(payment);
Solution: Validate strings with your schema:
// 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[]
const PAYMENTS = ['CHECK', 'DIRECT DEPOSIT']; // Inferred as string[]
PAYMENTS.push('INVALID'); // 💥 Runtime array corruption
Solution: Use readonly
arrays with as const
:
const PAYMENTS = ['CHECK', 'DIRECT DEPOSIT'] as const;
// ❌ Now errors at compile-time if modified:
PAYMENTS.push('INVALID');
Choosing the Right Approach
Method | Best For | When to Avoid |
---|---|---|
z.enum + as const | Most common cases | Complex validation logic |
z.union + z.literal | Custom per-value logic | Large option sets (>5 values) |
z.nativeEnum | Legacy code with existing enums | New 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.