@mantlejs/schema
TypeBox schema definition, Ajv validation, and data resolution for Mantle JS. Re-exports the TypeBox builder so a single schema definition produces both a TypeScript type and a validated runtime contract. Uses Ajv with ajv-formats for RFC-compliant string format validation out of the box.
Installation
Section titled “Installation”npm install @mantlejs/schema@sinclair/typebox, ajv, and ajv-formats ship as direct dependencies — no separate install needed.
Concepts
Section titled “Concepts”Why TypeBox + Ajv
Section titled “Why TypeBox + Ajv”The two libraries split the job along their strengths instead of overlapping: TypeBox is the
authoring surface — one schema definition yields both the TypeScript type and a JSON Schema
object, with no codegen step. Ajv is the validation engine — it JIT-compiles each schema into
an optimized validator function (cached per schema, see validate() below) and implements the
JSON Schema spec, including string formats, more completely than TypeBox’s own built-in
Value.Check runtime checker, which skips format validation entirely. Using TypeBox alone would
mean either no format validation or hand-rolling it; using Ajv alone would mean hand-writing JSON
Schema with no inferred TypeScript type. Combined, you define a schema once and get compile-time
types plus spec-compliant, high-performance runtime validation from it — the trade-off is that Ajv
becomes a hard runtime dependency (see the BYOV escape hatch below if that’s unacceptable).
One schema, two outputs
Section titled “One schema, two outputs”TypeBox generates a TypeScript type and a JSON Schema object from the same definition, with no build step:
import { Type, Static } from "@mantlejs/schema";
const UserSchema = Type.Object({ id: Type.String({ format: "uuid" }), email: Type.String({ format: "email" }), name: Type.String({ minLength: 1, maxLength: 100 }), password: Type.Optional(Type.String({ minLength: 8 })), createdAt: Type.String({ format: "date-time" }), updatedAt: Type.String({ format: "date-time" }),});
type User = Static<typeof UserSchema>;// ^ { id: string; email: string; name: string; password?: string; createdAt: string; updatedAt: string }RFC-compliant format validation
Section titled “RFC-compliant format validation”@mantlejs/schema uses Ajv with ajv-formats for validation. Common JSON Schema string formats are validated against the relevant RFCs out of the box:
| Format | RFC |
|---|---|
email |
RFC 5321/5322 |
date-time |
RFC 3339 |
date |
ISO 8601 |
uuid |
RFC 4122 |
uri |
RFC 3986 |
ipv4 / ipv6 |
RFC 791 / RFC 4291 |
No setup required — formats are validated automatically when you add them to a TypeBox schema.
Validation vs resolution
Section titled “Validation vs resolution”validate()— runs in abeforehook. Validates incoming data against the schema using Ajv. ThrowsUnprocessablewith field-level errors on failure.resolver()— runs in anafterhook. Transforms the result field-by-field: strip sensitive data, compute derived fields, or join related records.
Quick start
Section titled “Quick start”import { Type, Static, validate, resolver } from "@mantlejs/schema";
const UserSchema = Type.Object({ id: Type.String({ format: "uuid" }), email: Type.String({ format: "email" }), name: Type.String({ minLength: 1 }), password: Type.Optional(Type.String()),});
type User = Static<typeof UserSchema>;
app.use("/users", new UserService(new UserRepository(app)), { methods: ["find", "get", "create", "update", "patch", "remove"], schema: UserSchema,});
app.service("users").hooks({ before: { create: [validate(UserSchema)], update: [validate(UserSchema)], }, after: { all: [ resolver<User>({ password: () => undefined, // returning undefined removes the field }), ], },});
UserSchemaappears in three places above, but only one of them does anything at request time. Theschemapassed toapp.use()is inert metadata — it’s stored for tooling introspection (describe(),GET /_services, OpenAPI/MCP generation later) and never validates or transforms a request on its own. Actual enforcement comes only from thevalidate(UserSchema)hooks inbefore, and thepasswordstrip only happens because of the explicitresolver()inafter. Passingschematoapp.use()without also wiringvalidate()hooks means nothing is validated. (Exception:RepositoryServicereadsschema.properties[key].typeto coercewhere-clause query values — see “String coercion” underRepositoryServicein the@mantlejs/mantleREADME — but that’s query coercion, not body validation.)
validate(schema, options?)
Section titled “validate(schema, options?)”Before hook. Validates context.data (default), context.result, or context.params.query against a TypeBox schema using Ajv. Throws Unprocessable with field-level error details on failure.
Compiled validators are cached per schema object reference — Ajv compiles once on first use, not on every request.
function validate<T extends TSchema>(schema: T, options?: ValidateOptions): HookFunction;
interface ValidateOptions { /** What to validate. Default: 'data' */ target?: "data" | "result" | "query"; /** Coerce string inputs to their schema types (e.g. "42" → 42). Default: false */ coerce?: boolean; /** Strip properties not declared in the schema. Default: false */ stripAdditional?: boolean;}coerce uses TypeBox’s Value.Convert and stripAdditional uses Value.Clean as pre-processing steps before Ajv validates. The transformed value is written back to the context target.
app.service("users").hooks({ before: { create: [validate(UserSchema)], // coerce query string params ("limit=10") to numbers before validating find: [validate(QuerySchema, { target: "query", coerce: true })], },});Validation error format:
// HTTP 422 Unprocessable Entity{ name: "Unprocessable", code: 422, message: "Validation failed", data: { errors: [ { field: "/email", message: "must match format \"email\"" }, { field: "/name", message: "must NOT have fewer than 1 characters" }, ], },}validate(validator, options?) — BYOV
Section titled “validate(validator, options?) — BYOV”Pass a function as the first argument to bypass Ajv entirely and use any validation library. The function receives the raw data and returns either an array of { field, message } errors (triggers Unprocessable) or null/undefined/empty array (passes).
type ValidatorFn = (data: unknown) => Array<{ field: string; message: string }> | null | undefined;
function validate(validator: ValidatorFn, options?: Pick<ValidateOptions, "target">): HookFunction;coerce and stripAdditional do not apply — the custom validator handles any transforms.
import { z } from "zod";import type { ValidatorFn } from "@mantlejs/schema";
const UserZod = z.object({ email: z.string().email(), name: z.string().min(1) });
const zodValidator: ValidatorFn = (data) => { const result = UserZod.safeParse(data); if (result.success) return null; return result.error.issues.map((i) => ({ field: "/" + i.path.join("/"), message: i.message, }));};
app.service("users").hooks({ before: { create: [validate(zodValidator)] },});Ajv is a hard dependency of
@mantlejs/schemaand is always loaded. BYOV replaces the validation logic, not the package dependency. If removing Ajv entirely is a requirement, skip@mantlejs/schemaand throwUnprocessablefrom a plain hook function.
querySyntax(entitySchema, options?)
Section titled “querySyntax(entitySchema, options?)”Derives a query schema from an entity schema, following the RepositoryService query convention: every entity field accepts either a bare value or an operator object ($gt, $gte, $lt, $lte, $ne, $in, $nin — plus $like, $notlike, $ilike on string fields), and the reserved keys $limit, $skip, $sort, $select, $or, $and are typed to match. Unknown fields and unknown operators are rejected.
A plain entity schema rejects operator objects ({ age: { $gt: 21 } } is not a valid User) — use querySyntax for the query target instead:
import { querySyntax, validate } from "@mantlejs/schema";
const UserQuery = querySyntax(UserSchema, { maxLimit: 100 });
app.service("users").hooks({ before: { find: [validate(UserQuery, { target: "query", coerce: true })] },});With coerce: true, query-string values are converted to the field’s declared type before validation — ?age[$gt]=21&$limit=10 validates with 21 and 10 as numbers, ready for RepositoryService.
| Option | Type | Default | Description |
|---|---|---|---|
maxLimit |
number |
unlimited | Upper bound enforced on $limit |
resolver(map, options?)
Section titled “resolver(map, options?)”After hook. Iterates the field map and calls each resolver with the current field value, the full record, the hook context, and an optional shared context. Returning undefined removes the field entirely. Supports single records (T), arrays (T[]), and paginated results (Paginated<T>).
type FieldResolver<T, K extends keyof T, C = undefined> = ( value: T[K] | undefined, data: T, context: HookContext, shared: C,) => Promise<T[K] | undefined> | T[K] | undefined;
type ResolverMap<T, C = undefined> = { [K in keyof T]?: FieldResolver<T, K, C>;};
interface ResolverOptions<T, C> { createContext?: (record: T, ctx: HookContext) => Promise<C> | C;}
function resolver<T, C = undefined>(map: ResolverMap<T, C>, options?: ResolverOptions<T, C>): HookFunction;app.service("users").hooks({ after: { all: [ resolver<User>({ password: () => undefined, // strip sensitive field fullName: (_, data) => `${data.firstName} ${data.lastName}`, // compute derived field avatar: async (_, data) => fetchAvatar(data.id), // async lookup }), ], },});Shared context
Section titled “Shared context”createContext is called once per record before field resolvers run. Its return value is passed to every field resolver as the fourth argument. Use this to perform a single expensive async lookup shared across multiple fields:
resolver<User, { isAdmin: boolean }>( { role: (_, data, ctx, shared) => shared.isAdmin ? data.role : "viewer", badge: (_, data, ctx, shared) => shared.isAdmin ? "admin" : null, }, { createContext: async (record) => ({ isAdmin: await checkAdmin(record.id) }), },)For array and paginated results, createContext is called once per element. Existing field resolvers that ignore the fourth argument continue to work — TypeScript allows functions with fewer parameters.
Schema registration on a service
Section titled “Schema registration on a service”Pass schema to app.use() to store it for tooling introspection (CLI code generation, future OpenAPI output):
app.use("/users", new UserService(repo), { methods: ["find", "get", "create", "update", "patch", "remove"], schema: UserSchema,});
app.service("users").schema; // → TSchema | undefinedTypeBox re-exports
Section titled “TypeBox re-exports”@mantlejs/schema re-exports the TypeBox builder and common type helpers:
import { Type, Static, FormatRegistry } from "@mantlejs/schema";import type { TSchema, TObject, TString, TNumber, TBoolean, TArray, TOptional } from "@mantlejs/schema";import type { ValidateOptions, ValidatorFn, ResolverMap, FieldResolver, ResolverOptions } from "@mantlejs/schema";import type { TSchema, Static } from "@mantlejs/schema";| Type | Description |
|---|---|
TSchema |
Base TypeBox schema type |
Static<T> |
Infers the TypeScript type from a schema |
ValidateOptions |
Options for the TypeBox + Ajv overload of validate() |
ValidatorFn |
Custom validator function signature for the BYOV overload |
ResolverMap<T, C> |
Field-keyed map of resolver functions |
FieldResolver<T, K, C> |
Single field resolver function signature |
ResolverOptions<T, C> |
Options for resolver(), including createContext |
Development
Section titled “Development”npx nx build schema # compilenpx nx test schema # run testsnpx nx lint schema # lintPublishing
Section titled “Publishing”Build before publishing:
npx nx build schemaFirst publish (scoped packages require --access public):
cd packages/schemanpm publish --access publicSubsequent releases — bump version in packages/schema/package.json, then:
cd packages/schemanpm publishTesting locally with Verdaccio
Section titled “Testing locally with Verdaccio”# Terminal 1 — start the local registrynpx nx run @mantle/source:local-registry
# Terminal 2 — publish to itcd packages/schemanpm publish --registry http://localhost:4873
# Install from it in another projectnpm install @mantlejs/schema --registry http://localhost:4873