Discriminated Unions
What is a discriminated union
A discriminated union is a z.union() where each member schema is a z.object() with a shared field whose value is a distinct z.literal(). The shared field (the discriminator) identifies which shape the input belongs to.
Basic example
final eventSchema = z.union([
z.object({
'type': z.literal('click'),
'x': z.integer(),
'y': z.integer(),
}),
z.object({
'type': z.literal('keypress'),
'key': z.string(),
}),
]).discriminatedBy('type');
eventSchema.parse({'type': 'click', 'x': 100, 'y': 200});
eventSchema.parse({'type': 'keypress', 'key': 'Enter'});
eventSchema.parse({'type': 'unknown'}); // fails — invalid_union
Without .discriminatedBy(), z.union() tries each schema in order until one succeeds. With .discriminatedBy('type'), the schema reads the 'type' field, finds the member with a matching z.literal(), and validates against only that member. Unknown discriminator values fail immediately without trying any schema.
z.union() without a discriminator
For unions that do not share a common type field, omit .discriminatedBy(). Schemas are tried in order and the first match wins:
final idSchema = z.union([
z.string().uuid(),
z.integer().positive(),
]);
idSchema.parse('550e8400-e29b-41d4-a716-446655440000'); // string branch
idSchema.parse(42); // integer branch
idSchema.parse(true); // fails — invalid_union
Place more specific schemas before broader ones. A z.literal('admin') should come before z.string() if you need to distinguish the literal value.
Dispatch after validation
After safeParse() succeeds, switch on the discriminator field to handle each variant:
final result = eventSchema.safeParse(rawEvent);
switch (result) {
case ZemaSuccess(:final value):
switch (value['type']) {
case 'click':
final x = value['x'] as int;
final y = value['y'] as int;
print('click at ($x, $y)');
case 'keypress':
final key = value['key'] as String;
print('key: $key');
}
case ZemaFailure(:final errors):
for (final issue in errors) {
print('${issue.pathString}: ${issue.message}');
}
}
Use extension types for typed access to each variant's fields:
extension type ClickEvent(Map<String, dynamic> _) {
String get type => _['type'] as String;
int get x => _['x'] as int;
int get y => _['y'] as int;
}
extension type KeypressEvent(Map<String, dynamic> _) {
String get type => _['type'] as String;
String get key => _['key'] as String;
}
switch (result) {
case ZemaSuccess(:final value):
switch (value['type']) {
case 'click':
final event = ClickEvent(value);
print('click at (${event.x}, ${event.y})');
case 'keypress':
final event = KeypressEvent(value);
print('key: ${event.key}');
}
case ZemaFailure(:final errors):
// ...
}
Real-world examples
API events
final apiEventSchema = z.union([
z.object({
'type': z.literal('user.created'),
'userId': z.string().uuid(),
'email': z.string().email(),
'timestamp': z.dateTime(),
}),
z.object({
'type': z.literal('user.deleted'),
'userId': z.string().uuid(),
'reason': z.string(),
'timestamp': z.dateTime(),
}),
z.object({
'type': z.literal('user.updated'),
'userId': z.string().uuid(),
'changes': z.object({
'email': z.string().email().optional(),
'name': z.string().optional(),
}),
'timestamp': z.dateTime(),
}),
]).discriminatedBy('type');
Payment methods
final paymentMethodSchema = z.union([
z.object({
'method': z.literal('credit_card'),
'cardNumber': z.string().regex(RegExp(r'^\d{13,19}$')),
'cvv': z.string().regex(RegExp(r'^\d{3,4}$')),
'expiry': z.string().regex(RegExp(r'^\d{2}/\d{2}$')),
}),
z.object({
'method': z.literal('paypal'),
'email': z.string().email(),
}),
z.object({
'method': z.literal('bank_transfer'),
'accountNumber': z.string(),
'routingNumber': z.string(),
'bankName': z.string(),
}),
z.object({
'method': z.literal('crypto'),
'currency': z.string().oneOf(['BTC', 'ETH', 'USDT']),
'walletAddress': z.string(),
}),
]).discriminatedBy('method');
Future<void> processPayment(Map<String, dynamic> data) async {
final result = paymentMethodSchema.safeParse(data);
switch (result) {
case ZemaSuccess(:final value):
switch (value['method']) {
case 'credit_card': await processCreditCard(value);
case 'paypal': await processPayPal(value);
case 'bank_transfer': await processBankTransfer(value);
case 'crypto': await processCrypto(value);
}
case ZemaFailure(:final errors):
throw ValidationException(errors);
}
}
Order state machine
final orderStateSchema = z.union([
z.object({
'status': z.literal('pending'),
'createdAt': z.dateTime(),
}),
z.object({
'status': z.literal('processing'),
'startedAt': z.dateTime(),
'completesAt': z.dateTime(),
}),
z.object({
'status': z.literal('completed'),
'completedAt': z.dateTime(),
'total': z.double().positive(),
}),
z.object({
'status': z.literal('failed'),
'failedAt': z.dateTime(),
'errorCode': z.string(),
'retryable': z.boolean(),
}),
z.object({
'status': z.literal('cancelled'),
'cancelledAt': z.dateTime(),
'reason': z.string(),
}),
]).discriminatedBy('status');
Notification types
final notificationSchema = z.union([
z.object({
'type': z.literal('email'),
'to': z.string().email(),
'subject': z.string(),
'body': z.string(),
}),
z.object({
'type': z.literal('sms'),
'to': z.string(),
'message': z.string().max(160),
}),
z.object({
'type': z.literal('push'),
'deviceToken': z.string(),
'title': z.string(),
'body': z.string(),
}),
z.object({
'type': z.literal('in_app'),
'userId': z.string().uuid(),
'message': z.string(),
'priority': z.string().oneOf(['low', 'medium', 'high']).withDefault('medium'),
}),
]).discriminatedBy('type');
Requirements for discriminatedBy()
- Every member must be a
z.object(). - Each member must have a
z.literal()at the discriminator key. - Literal values must be distinct across members.
If the input's discriminator value does not match any literal, invalid_union is returned immediately. If any member does not follow these requirements, the linear scan fallback is used for that member.
API reference
z.union(schemas)
ZemaUnion<T> union<T>(List<ZemaSchema<dynamic, T>> schemas)
Returns a union schema that accepts any value matching at least one of the given schemas. Schemas are tried in declaration order. Returns an invalid_union issue if all schemas fail.
.discriminatedBy(field)
ZemaUnion<T> discriminatedBy(String field)
Returns a new union schema that uses the value at field to select the matching member schema directly. Skips the linear scan and fails immediately when no member's literal matches the discriminator value.
Next steps
- Merging and Extending: compose object schemas with
extend()andmerge() - Picking and Omitting: create field subsets from an object schema
- Custom Validators: cross-field validation with
.superRefine()