Zema Logo
Composition

Merging and Extending Schemas

Compose object schemas using extend(): add fields, override constraints, and build reusable base 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

Copyright © 2026