Merging and Extending Schemas
extend()
extend() returns a new ZemaObject schema that combines the fields of the original schema with an additional shape. When both share a key, the field from the argument takes precedence.
final baseSchema = z.object({
'id': z.string().uuid(),
'createdAt': z.dateTime(),
'updatedAt': z.dateTime(),
});
final userSchema = baseSchema.extend({
'email': z.string().email(),
'name': z.string(),
});
// userSchema validates: id, createdAt, updatedAt, email, name
userSchema.parse({
'id': '550e8400-e29b-41d4-a716-446655440000',
'createdAt': DateTime.now(),
'updatedAt': DateTime.now(),
'email': 'alice@example.com',
'name': 'Alice',
});
The original schema is not modified. extend() returns a new schema instance.
merge()
merge() combines two ZemaObject schemas. It takes another schema instance rather than a raw shape map. Fields from the argument schema override fields with the same key.
final base = z.object({'name': z.string(), 'age': z.integer()});
final override = z.object({'age': z.integer().gte(18), 'email': z.string().email()});
final merged = base.merge(override);
// merged validates: name (from base), age (from override — gte(18)), email (from override)
merge() and extend() produce identical results. The difference is the argument type: extend() takes a raw Map<String, ZemaSchema>, while merge() takes a ZemaObject. Use whichever is clearer at the call site.
// These are equivalent:
base.extend(override.shape);
base.merge(override);
Overriding fields
If both schemas define the same key, the argument field replaces the base field. This is how you tighten or loosen constraints on an inherited field:
final baseSchema = z.object({
'name': z.string(),
'price': z.double().positive(),
});
// Override price to require a minimum value
final premiumSchema = baseSchema.extend({
'price': z.double().gte(100.0),
});
Common patterns
Base entity schema
Define shared audit fields once and extend for each entity:
final entitySchema = z.object({
'id': z.string().uuid(),
'createdAt': z.dateTime(),
'updatedAt': z.dateTime(),
});
final userSchema = entitySchema.extend({
'email': z.string().email(),
'name': z.string(),
'role': z.string().oneOf(['admin', 'user']),
});
final postSchema = entitySchema.extend({
'title': z.string(),
'content': z.string(),
'authorId': z.string().uuid(),
});
final commentSchema = entitySchema.extend({
'content': z.string(),
'postId': z.string().uuid(),
'authorId': z.string().uuid(),
});
Reusable field groups
Compose field groups as schemas and extend into domain schemas:
final timestampsSchema = z.object({
'createdAt': z.dateTime(),
'updatedAt': z.dateTime(),
});
final auditSchema = z.object({
'createdBy': z.string().uuid(),
'updatedBy': z.string().uuid().optional(),
'deletedBy': z.string().uuid().optional(),
'deletedAt': z.dateTime().optional(),
});
final documentSchema = z.object({
'id': z.string().uuid(),
'title': z.string(),
'content': z.string(),
})
.extend(timestampsSchema.shape)
.extend(auditSchema.shape);
Shared address
final addressSchema = z.object({
'street': z.string(),
'city': z.string(),
'zipCode': z.string(),
'country': z.string(),
});
final userSchema = z.object({
'id': z.string().uuid(),
'name': z.string(),
'billingAddress': addressSchema,
'shippingAddress': addressSchema.optional(),
});
Inheritance-like composition
final personSchema = z.object({
'firstName': z.string(),
'lastName': z.string(),
'dateOfBirth': z.dateTime(),
});
final employeeSchema = personSchema.extend({
'employeeId': z.string(),
'department': z.string(),
'salary': z.double().positive(),
});
final customerSchema = personSchema.extend({
'customerId': z.string(),
'loyaltyPoints': z.integer().gte(0).withDefault(0),
});
Versioned schemas
// Version 1
final userV1 = z.object({
'id': z.integer(),
'name': z.string(),
});
// Version 2: email added as optional for backward compatibility
final userV2 = userV1.extend({
'email': z.string().email().optional(),
});
// Version 3: email required, role added
final userV3 = userV2.extend({
'email': z.string().email(), // overrides optional with required
'role': z.string().withDefault('user'),
});
Conditional extension
final baseSchema = z.object({
'type': z.string().oneOf(['basic', 'premium']),
'name': z.string(),
});
final schema = kDebugMode
? baseSchema.extend({
'debugInfo': z.object({
'timestamp': z.dateTime(),
'source': z.string(),
}),
})
: baseSchema;
Real-world example: e-commerce
final entitySchema = z.object({
'id': z.string().uuid(),
'createdAt': z.dateTime(),
'updatedAt': z.dateTime(),
});
final priceSchema = z.object({
'price': z.double().positive(),
'currency': z.string().length(3).withDefault('USD'),
'discount': z.double().gte(0).lte(1).optional(),
});
final productSchema = z.object({
'name': z.string(),
'description': z.string(),
'sku': z.string(),
'stock': z.integer().gte(0),
'categoryId': z.string().uuid(),
})
.extend(entitySchema.shape)
.extend(priceSchema.shape);
final orderItemSchema = priceSchema.extend({
'productId': z.string().uuid(),
'quantity': z.integer().positive(),
'total': z.double().positive(),
});
final orderSchema = entitySchema.extend({
'customerId': z.string().uuid(),
'items': z.array(orderItemSchema).min(1),
'subtotal': z.double().positive(),
'tax': z.double().gte(0),
'total': z.double().positive(),
'status': z.string().oneOf(['pending', 'paid', 'shipped', 'delivered', 'cancelled']),
});
API reference
extend(shape)
ZemaObject<Map<String, dynamic>> extend(
Map<String, ZemaSchema> additionalShape,
)
Returns a new schema combining the existing shape with additionalShape. Fields from additionalShape override fields with the same key. The original schema is not modified. The returned schema always has output type Map<String, dynamic>.
merge(other)
ZemaObject<Map<String, dynamic>> merge(ZemaObject<dynamic> other)
Returns a new schema combining the existing shape with the shape of other. Fields from other override fields with the same key. Equivalent to extend(other.shape). The original schema is not modified.
Next steps
- Picking and Omitting: select or exclude fields from a schema
- Discriminated Unions: dispatch validation based on a type field
- Objects:
z.object()andz.objectAs()