Schema Extension & Reusability
.extend() provides powerful schema reuse with full type inference, eliminating the need for $ref in most cases.
Basic extension
const timestampSchema = new SchemaBuilder()
.object()
.properties({
createdAt: (s) => s.number(),
updatedAt: (s) => s.number(),
})
.required(["createdAt"])
.build();
const userSchema = new SchemaBuilder()
.extend(timestampSchema)
.properties({
id: (s) => s.number(),
name: (s) => s.string(),
email: (s) => s.string(),
})
.required(["id", "name", "email"])
.build();
type User = Jet.Infer<typeof userSchema>;
// {
// createdAt: number; // from base
// updatedAt?: number; // from base
// id: number; // added
// name: string; // added
// email: string; // added
// }How extension works
.extend() inherits the complete state of the existing schema, then you build on top.
const baseSchema = new SchemaBuilder()
.object()
.properties({ id: (s) => s.number() })
.build();
const extendedSchema = new SchemaBuilder()
.extend(baseSchema)
.properties({ name: (s) => s.string() })
.build();
type Extended = Jet.Infer<typeof extendedSchema>;
// { id?: number; name?: string }Each method call replaces, merges, or creates a union for its keyword. For types specifically, adding a type creates a union:
const schema1 = new SchemaBuilder().string().build();
const schema2 = new SchemaBuilder().extend(schema1).number().build();
type Schema2 = Jet.Infer<typeof schema2>;
// string | number (not just number)This ensures types stay consistent, extension is predictable, and schemas work as reusable templates.
Why extension beats $ref
$ref resolves at validation time but gives TypeScript nothing.
// With $ref — no usable type
new SchemaBuilder()
.$defs({ user: (s) => s.object().properties({ id: (s) => s.number() }) })
.properties({ author: (s) => s.$ref("#/$defs/user") });
// Type: { author?: unknown } ❌
// With .extend() — fully typed
const userSchema = new SchemaBuilder()
.object()
.properties({ id: (s) => s.number() })
.build();
new SchemaBuilder()
.object()
.properties({ author: (s) => s.extend(userSchema) });
// Type: { author?: { id?: number } } âś…Benefits: type inference works perfectly, no $ref resolution needed, maximum
reusability, schemas become composable building blocks, and dependencies stay explicit.
Multiple extensions
const idSchema = new SchemaBuilder()
.object()
.properties({ id: (s) => s.string().format("uuid") })
.required(["id"])
.build();
const timestampSchema = new SchemaBuilder()
.object()
.properties({
createdAt: (s) => s.number(),
updatedAt: (s) => s.number(),
})
.required(["createdAt"])
.build();
const entitySchema = new SchemaBuilder()
.extend(idSchema)
.allOf((s) => s.extend(timestampSchema))
.properties({
name: (s) => s.string(),
description: (s) => s.string(),
})
.required(["name"])
.build();
type Entity = Jet.Infer<typeof entitySchema>;
// {
// id: string; // from idSchema
// createdAt: number; // from timestampSchema via allOf
// name: string; // added
// updatedAt?: number;
// description?: string;
// }Common patterns
Trait composition
const identifiable = new SchemaBuilder()
.object()
.properties({ id: (s) => s.string() })
.required(["id"])
.build();
const nameable = new SchemaBuilder()
.object()
.properties({ name: (s) => s.string() })
.required(["name"])
.build();
const product = new SchemaBuilder()
.extend(identifiable)
.allOf((s) => s.extend(nameable))
.properties({
price: (s) => s.number(),
inStock: (s) => s.boolean(),
})
.required(["price", "inStock"])
.build();Base + variants
const baseUser = new SchemaBuilder()
.object()
.properties({
id: (s) => s.number(),
email: (s) => s.string(),
})
.required(["id", "email"])
.build();
const adminUser = new SchemaBuilder()
.extend(baseUser)
.properties({
role: (s) => s.const("admin"),
permissions: (s) => s.array().items((s) => s.string()),
})
.required(["role", "permissions"])
.build();
const regularUser = new SchemaBuilder()
.extend(baseUser)
.properties({ role: (s) => s.const("user") })
.required(["role"])
.build();Incremental building
let schema = new SchemaBuilder().object();
schema = new SchemaBuilder()
.extend(schema)
.properties({
id: (s) => s.number(),
name: (s) => s.string(),
});
if (needsTimestamps) {
schema = new SchemaBuilder()
.extend(schema)
.properties({
createdAt: (s) => s.number(),
updatedAt: (s) => s.number(),
});
}
const final = schema.build();Merge vs replace
When you extend and add keywords, some merge with the base and some replace it.
Keywords that MERGE (additive):
properties— object properties mergepatternProperties— pattern properties mergerequired— required arrays union together
const baseSchema = new SchemaBuilder()
.object()
.properties({
id: (s) => s.number(),
createdAt: (s) => s.number(),
})
.required(["id"])
.build();
const extendedSchema = new SchemaBuilder()
.extend(baseSchema)
.properties({
name: (s) => s.string(),
email: (s) => s.string(),
})
.required(["name"])
.build();
type Extended = Jet.Infer<typeof extendedSchema>;
// {
// id: number; // from base (required)
// createdAt?: number; // from base (optional)
// name: string; // added (required)
// email?: string; // added (optional)
// }
// required merged: ["id", "name"]Keywords that REPLACE (override):
- Combinators:
oneOf,anyOf,allOf,not - Conditionals:
if,then,elseIf,else - Literals:
const,enum - Array structure:
items,prefixItems,additionalItems,unevaluatedItems,contains - Object constraints:
additionalProperties,unevaluatedProperties
const arrSchema = new SchemaBuilder().array().items((s) => s.string());
const arr2Schema = new SchemaBuilder()
.extend(arrSchema)
.items((s) => s.number()) // replaced with number
.build();Special case — types union instead of replacing:
const stringSchema = new SchemaBuilder().string().minLength(5).build();
const numberSchema = new SchemaBuilder()
.extend(stringSchema)
.number() // you get string | number
.minimum(0)
.build();
type Result = Jet.Infer<typeof numberSchema>;
// string | number — constraints apply to their individual typesA replace example with object constraints:
const baseConfig = new SchemaBuilder()
.object()
.properties({
host: (s) => s.string(),
port: (s) => s.number(),
})
.required(["host"])
.additionalProperties((s) => s.boolean()) // extra props must be boolean
.build();
const strictConfig = new SchemaBuilder()
.extend(baseConfig)
.properties({ timeout: (s) => s.number() })
.additionalProperties(false) // REPLACES: now no extra props
.build();
type StrictConfig = Jet.Infer<typeof strictConfig>;
// { host: string; port?: number; timeout?: number }remove and optional
remove
Removes defined properties from properties, required, patternProperties, dependencies, or dependentRequired. Default target is ["properties"].
const logEntrySchema = new SchemaBuilder()
.object()
.properties({
key: (s) => s.string(),
value: (s) => s.string(),
})
.required(["key", "value"])
.build();
// { key: string; value: string }
// Remove from required only → key becomes optional
const schema3 = new SchemaBuilder()
.extend(logEntrySchema)
.properties({ jon: (s) => s.string() })
.required(["jon"])
.remove(["key"], ["required"])
.build();
// { value: string; jon: string; key?: string }
// Remove from properties → key removed entirely
const schema4 = new SchemaBuilder()
.extend(logEntrySchema)
.properties({ jon: (s) => s.string() })
.required(["jon"])
.remove(["key"], ["properties"])
.build();
// { value: string; jon: string }
// Multiple values and keywords at once
const schema5 = new SchemaBuilder()
.extend(logEntrySchema)
.properties({ jon: (s) => s.string() })
.required(["jon"])
.remove(["key", "jon"], ["properties", "required", "dependencies"])
.build();optional
Deletes the required array entirely — every property becomes optional.
const schema2 = new SchemaBuilder()
.extend(logEntrySchema)
.properties({ jon: (s) => s.string() })
.optional()
.build();
// { key?: string; value?: string; jon?: string }Practical examples
Building user variants
const baseUser = new SchemaBuilder()
.object()
.properties({
id: (s) => s.string(),
email: (s) => s.string().format("email"),
createdAt: (s) => s.number(),
})
.required(["id", "email"])
.build();
const adminUser = new SchemaBuilder()
.extend(baseUser)
.properties({
role: (s) => s.const("admin"),
permissions: (s) => s.array().items((s) => s.string()),
})
.required(["role", "permissions"])
.build();
type AdminUser = Jet.Infer<typeof adminUser>;
// {
// id: string; email: string; // from base (required)
// role: "admin"; permissions: string[]; // added (required)
// createdAt?: number; // from base (optional)
// }Combining merge and replace
const baseProduct = new SchemaBuilder()
.object()
.properties({
id: (s) => s.string(),
name: (s) => s.string(),
})
.required(["id"])
.oneOf(
(s) => s.object().properties({ type: (s) => s.const("digital") }),
(s) => s.object().properties({ type: (s) => s.const("physical") }),
)
.build();
const extendedProduct = new SchemaBuilder()
.extend(baseProduct)
.properties({ price: (s) => s.number() }) // MERGES
.required(["price"]) // MERGES
.oneOf(
// REPLACES the original oneOf entirely
(s) => s.object().properties({ category: (s) => s.const("electronics") }),
(s) => s.object().properties({ category: (s) => s.const("clothing") }),
)
.build();
type ExtendedProduct = Jet.Infer<typeof extendedProduct>;
// {
// id: string; name?: string; price: number;
// category: "electronics" | "clothing"; // from the NEW oneOf
// }
// The original digital/physical oneOf was replaced.