Onboarding and technical details
Welcome to the json-rest-schema project! This document aims to demystify the library's internal structure and design philosophy. If you're looking to contribute, extend its functionality, or simply understand how it works under the hood, you're in the right place.
1. The Core Philosophy: Simplicity and Extensibility
Our primary goal for this library is to provide a robust, extensible, and easy-to-understand mechanism for defining and validating data schemas. We achieve this by:
- Factory-Scoped Registries with a Simple Default Entry Point: Type casting functions and validation rules are exposed from one familiar public entry point, but each schema factory still owns its own registries.
- Clear Separation of Concerns: The responsibility for defining types/validators is separate from the responsibility for applying them during validation.
- Plugin-Based Extensibility: New types and validators can be seamlessly added through a straightforward plugin system.
- Direct API Access: Key functions for interacting with the library are exposed directly, minimizing layers of abstraction.
- Synchronous Shared Contracts: Schemas are intentionally synchronous so the same contract can run on both client and server without pulling in database, network, or other stateful dependencies.
Because of that last point, this library owns typing, casting, normalization, and local validation only. Business rules that require I/O belong in higher layers such as services, repositories, or actions.
2. The Architecture - A Deep Dive
Let's walk through the main components and how they interact:
2.1. ./src/ Layout
The source tree now stays intentionally shallow:
./src/index.jsis the public entry point./src/core/contains the runtime validation engine and transport export./src/core/nested-contract.jskeeps nested contract shape parsing shared between runtime validation and transport export./src/utils/contains shared helper utilities./src/adapters/contains optional UI-library integration layers
That split is deliberate. It is enough structure to keep responsibilities easy to find, without turning a small library into a directory maze.
2.2. ./src/index.js - The Main Entry Point
This file is the true heart and soul of the library. It acts as the central coordinator, managing the global collection of type and validator handlers, and providing the primary interface for users.
Key Features of index.js:
Factory-Scoped Registries:
index.jsno longer uses one pair of plain global registry objects.- Instead, each schema factory owns its own
typesandvalidatorsregistries. That makes isolated factories possible without leaking local custom rules back into the defaultcreateSchemafactory. - The module uses
WeakMapmetadata to remember which registries belong to which schema factories and schema instances.
The Factory Constructor (
createSchemaFactory):createSchemaFactory(options)is the low-level constructor for creating a schema factory.- By default it installs the built-in core handlers, so
createSchemaFactory()is immediately usable for normal schemas. - If you want a completely bare registry, you must opt into it explicitly with
createSchemaFactory({ installCore: false }).
The Default Factory (
createSchema):createSchemais just the default factory created bycreateSchemaFactory({ installCore: true }).- Calling
createSchema(structure, options)directly instantiates a newSchemaobject from./src/core/Schema.js. - The
Schemainstance receives the current factory registries plus any per-schema operation descriptors. That is why schemas created by different factories can see different custom rules without interfering with one another.
Factory Methods (
addType,addValidator, anduse):- Every factory exposes
.addType,.addValidator, and.use. - These methods mutate only that factory's registries, not some hidden process-wide singleton.
- The plugin system still stays simple:
factory.use(plugin)callsplugin.install({ addType, addValidator }).
- Every factory exposes
Registry Cloning (
factory.createFactory(...)):- A factory can create another factory from existing schema instances or other schema factories.
- This is how you keep custom rules local while still reusing them intentionally in another context.
- Registry merging is strict: if two sources define the same type or validator name with different handlers, the merge throws instead of picking one silently.
Convenience Named Exports (
addType,addValidator, anduse):index.jsstill exports top-leveladdType,addValidator, andusefunctions for convenience.- These simply delegate to the default
createSchemafactory. - That preserves the familiar public API while still letting advanced callers create isolated factories when needed.
2.3. ./src/core/Schema.js - The Validation Workhorse
While index.js manages the global registries, the Schema class is where the actual validation magic happens for a specific schema definition.
Key Features of Schema.js:
- Self-Contained Validation Logic: Each instance of
Schemarepresents a single, defined data structure. It contains the logic to traverse an input object, apply type casting, and run validation rules against its ownstructure. - Dependency Injection in Constructor: Unlike the previous design, the
Schemaconstructor (constructor(structure, types, validators, operations)) now explicitly receives thetypes,validators, and per-schemaoperationsit needs from thecreateSchemafactory function. This makes its dependencies clear and improves its testability. - Operation Registry:
create,replace, andpatchare default operation descriptors stored in an internal registry. Additional operations can be declared per schema, and theSchemainstance automatically installs method aliases for them. Operation descriptors currently supporttargetFields,enforceRequired,applyDefaults,outputFields, andrejectExplicitUndefined. - Recursive Contract Support: Nested object contracts are expressed with
type: 'object'plusschema, typed object maps are expressed withtype: 'object'plusvalues, nested array items are expressed withtype: 'array'plusitems, and opaque or passthrough object bags are expressed withtype: 'object'plusadditionalProperties: true. Recursive schema graphs are supported across those nested schema edges. This is intentionally much narrower than general JSON Schema. _validateFieldMethod: This private method is the granular core of the validation. For each field in your schema, it synchronously orchestrates:- Pre-checks: Handling
requiredrules, skipping fields, and dealing withnullor empty values based on options. - Type Casting: It looks up the appropriate type handler from its received
this.typesregistry and attempts to transform the field's value. - Parameter Validation: It then iterates through any validation parameters defined for the field (e.g.,
min,max,validator), looks up their respective handlers inthis.validators, and applies them.
- Pre-checks: Handling
- Recursive Validation Helpers: After a field is cast, the
Schemainstance can recursively validate nested child schemas or array items. Nested object fields inherit the active operation contract, while array items that are object schemas are validated inreplacemode. Errors are flattened into dotted paths such asworkspace.slugandroles.2.id. - Operation Methods (
create,replace,patch, and custom aliases): These public synchronous methods are generated from the operation registry and orchestrate validation for a whole object. They identify allowed/disallowed fields, validate fields in a plain loop, collect all errors, and apply anydefaultTovalues required by the active operation contract. Names that already exist onSchemaare reserved, so aliases such asvalidateWith,toJsonSchema,getFieldDefinitions,getFieldDefinition,getFieldMessages, orcleanupare rejected. validateWith(operationName, object, options)Method: This is the canonical operation entry point. Generated aliases such ascreate()orupsert()simply delegate to it.- Field Introspection (
getFieldDefinitions(),getFieldDefinition(path), andgetFieldMessages(path)): These public helpers expose schema-defined field metadata without requiring callers to know about the internal storage shape. They return frozen snapshots instead of live definitions, so adapter code can inspect schema metadata without mutating runtime validation behavior.getFieldDefinition()follows dotted nested paths and numeric array segments such asworkspace.slugorroles.0.id.getFieldMessages()is the narrow accessor for field-level message overrides when callers do not need the whole field definition object. - Path-Scoped Validation (
validateAtandvalidatePaths): These methods reuse the same casting, validator, and nested recursion code, but they validate only the selected schema paths. They default topatchsemantics because that is the least surprising choice for interactive field validation. Exact selected paths can still opt intocreate,replace, or any custom operation.
A concrete mental model for nested validation:
- A top-level operation such as
create()orpatch()decides which fields are in scope. - Each field is cast by its type handler.
- If the field is:
- a nested object contract, the child schema is validated recursively with the parent operation
- an array with
items, each item is validated recursively - an object bag with
additionalProperties: true, recursion stops and the value passes through
- Validator parameters run after casting and recursive normalization.
- Any child errors are flattened back into the parent error map using dotted paths.
That sequencing is important. It means callers always see one flat { validatedObject, errors } result even though the implementation is recursive.
A concrete mental model for path-scoped validation:
validateAt()orvalidatePaths()builds a selection tree from the requested dotted paths.- The validator walks only that selected subtree.
- Exact selected paths can enforce
requiredand apply defaults according to the chosen operation. - Parent container nodes are only validated enough to support traversal into the selected child paths.
- Child and array-item recursion still uses the same type handlers, validator handlers, and nested schema logic as full-object validation.
That is why the subset APIs stay aligned with the main engine instead of becoming a second validation system with different behavior.
2.4. ./src/core/CorePlugin.js - The Default Toolbox
This file simply defines all the standard, built-in type and validator handlers that come with the library.
Key Features of CorePlugin.js:
- The
installMethod: This is the critical part. As discussed, itsinstallmethod is designed to receive theaddTypeandaddValidatorfunctions. It then calls these functions multiple times, registering all the core functionalities likestring,number,booleantypes, andmin,max,notEmptyvalidators. - Clear Definition of Default Handlers: It provides well-defined synchronous functions for common data transformations and validation checks, serving as excellent examples for how to write your own custom types and validators.
- Strict Object Ownership: The built-in
objecttype now validates that the incoming value is actually a plain object. That matters because nested contracts and opaque bags both depend on object-ness being explicit instead of being a loose pass-through.
2.5. ./src/core/transport-schema.js - Transport Export
This module turns a Schema instance into a draft-07 JSON Schema document for transport-layer adapters.
Key Features of transport-schema.js:
- Operation-aware export: The same operation descriptors used at runtime control
requiredfields and exported defaults. - Nested export: Schema-backed nested contracts are exported as graph-aware draft-07
definitionsplus$ref, so repeated and recursive schema graphs stay finite. Direct self-recursive object fields point back to#, while deeper recursive edges use nameddefinitions. - Intentional restraint: The exporter supports nested objects, nested arrays, and opaque object bags, but it does not grow into a general-purpose JSON Schema authoring layer. That line keeps the library readable and keeps runtime behavior aligned with export behavior.
Why the nested scope stays small:
We support only a small set of nested shapes because they cover most real shared REST contracts without creating a second generic schema language inside the library:
type: 'object'plusschematype: 'array'plusitemstype: 'object'plusvaluestype: 'object'plusadditionalProperties: truetype: 'object'plusschemaandadditionalProperties: true
We intentionally do not support arbitrary embedded JSON Schema or library-authored oneOf / anyOf trees. We do support recursive schema graphs by exporting them through draft-07 definitions and $ref, because the runtime schema model is a graph rather than a plain tree. The object support still stays narrow after the map/passthrough additions: known fields can come from schema, dynamic values can come from values, and passthrough extras are only enabled with the literal flag additionalProperties: true. That restraint is what keeps both the runtime and the docs understandable for a junior developer reading the code for the first time.
2.6. ./src/utils/error-helpers.js - Error Utilities
This module is intentionally small. It does not validate anything. It only helps consumers work with the existing flat error contract.
Current helpers:
getError(errors, path)for reading one dotted-path errorhasError(errors, path)for simple boolean checksnestErrors(errors)for turning flat dotted paths into nested object/array formflattenErrors(nestedErrors)for turning nested object/array errors back into the flat dotted-path map
This split is deliberate:
- the runtime keeps one stable flat error contract
- adapters and UI layers can reshape that contract when they need to
- the reverse helper keeps adapters from re-implementing dotted-path flattening inconsistently
That lets the core stay simple without forcing every consumer into the same form-library error shape.
2.7. ./src/adapters/react-hook-form.js - React Hook Form Adapter
This module is the first real UI-library adapter layer. It is intentionally not part of Schema.js.
What it does:
- accepts a
Schemainstance - maps RHF resolver calls into
validateWith()orvalidatePaths() - converts flat dotted-path errors into RHF's nested error shape
- supports RHF native validation hooks
Important design choices:
- Separate entry point: it is exported as
json-rest-schema/react-hook-forminstead of being folded into the core root API surface. - Full-form validation defaults to
create: that gives useful required/default behavior for common create forms. - Field-level re-validation uses path selection: when RHF re-validates a subset of fields during user interaction, the adapter validates only those selected paths.
- Raw-by-default interaction: field-level re-validation keeps raw values by default so normalization does not fight typing behavior. Callers can opt into normalized field-level values explicitly.
- Canonical submit payloads still belong to the schema boundary: in a real RHF app, the resolver validates correctly, but RHF still owns raw field state. If callers need a canonical REST payload, they should run one final schema operation in the submit handler. The React demo shows that pattern.
This is the pattern to preserve for future adapters: keep framework code thin, route everything through the existing schema engine, and do not duplicate validation rules in adapter land.
2.8. ./src/adapters/vue.js - Vue Form Adapter
This module is the Vue-facing equivalent of the React resolver, but the shape is different because Vue does not revolve around a resolver contract.
What it does:
- exposes
useSchemaForm(schema, options) - exposes
useSchemaField(form, path) - keeps form errors in the core flat dotted-path contract
- adds convenience getters such as
nestedErrors,hasErrors,getErrorMessages(), and field-level helpers - routes full-form validation through
validateWith() - routes field and subset validation through
validateField()/validateFields()
Important design choices:
- No direct Vue import: this module does not import
vue. It accepts plain objects, Vue reactive proxies, or ref-like{ value }containers the caller already owns. - Full-form validation defaults to
create: the adapter mirrors the same default as the React resolver for the common create-form case. - Selected-path error merging: field-level validation clears and replaces only the selected path errors. It does not wipe unrelated form errors every time a single field is re-validated.
- Raw-state ownership stays in the app: the adapter validates and normalizes, but it does not take over UI state management.
- Reactivity stays caller-owned: if a Vue app wants reactive error/result updates, it should pass Vue refs or reactive containers through
errors/lastResultinstead of expecting hidden Vue state inside the adapter.
That last point matters. This file is meant to help Vue forms use the schema layer cleanly, not to become a form state framework.
2.9. ./src/adapters/vuetify.js - Vuetify Bridge
This module is intentionally even smaller than ./src/adapters/vue.js.
What it does:
- exposes
createVuetifyRule(form, path) - exposes
getVuetifyErrorMessages(formOrErrors, path) - exposes
fieldProps(form, path, options)
Important design choices:
- Thin bridge only: these helpers translate schema results into Vuetify's
rulesanderror-messagesconventions. They do not own validation logic themselves. fieldProps()defaults torulesonly: Vuetify merges manualerror-messageswith rule-generated messages, so returning both by default would duplicate the same message on screen.- Schema remains the source of truth: the rule helper clones the current form values, injects the candidate field value, and then calls the existing Vue form adapter. That keeps all normalization and validation behavior in one place.
This separation is worth preserving. The Vuetify layer should stay as glue code, not as a second validation implementation.
2.10. ./demos/ and ./playwright.config.js - Runtime Demo Coverage
The demo apps live outside the publishable package surface and exist to prove the adapters in real browser runtimes.
What they do:
./demos/react-rhfexercises the React Hook Form resolver in a browser app./demos/vue-vuetifyexercises the Vue and Vuetify adapters in a browser app./demos/shared/workspace-demo-schema.jskeeps both demos on the same contract./playwright.config.jsstarts both Vite dev servers and runs smoke tests against them
Important design choices:
- Local source aliasing: each demo aliases
json-rest-schemaimports back to the localsrc/files, so the demos always test the current checkout. - Shallow scope: these are runtime proofs, not starter kits.
- Browser-first verification: Playwright exists here to catch issues unit tests will miss, such as Node-only imports leaking into the shared contract runtime.
2.11. ./src/adapters/vee-validate.js - VeeValidate v5 Bridge
This module is smaller still.
VeeValidate v5 already accepts Standard Schema-compatible validators as validationSchema, so the right integration is not a custom VeeValidate state manager. The right integration is a tiny bridge that wraps a Schema instance in the Standard Schema shape.
What it does:
- exposes
toVeeValidateSchema(schema, options) - returns an object with a
~standard.validate()method - validates through
schema.validateWith(...) - returns normalized output on success
- converts the flat error map into Standard Schema
issueswith nested path segments
Important design choices:
- No VeeValidate import: the bridge has no runtime dependency on
vee-validate. - Default operation is
create: that matches the other form adapters. - Standard Schema path output: issue paths become arrays like
['roles', 0, 'label']so VeeValidate can map nested array/object errors back to fields. - Configuration errors still throw: invalid schema instances or invalid operation configuration are programmer errors and should fail loudly. Validation failures return
issues.
There is also one VeeValidate-specific caveat worth preserving in the docs: Standard Schema validation can normalize submitted output, but VeeValidate still expects callers to supply their own initialValues. Schema defaults do not magically pre-populate the UI state.
3. Docs and Site Generation
The docs site is intentionally generated from the same markdown contributors edit in the repo, instead of maintaining a second hand-written manual.
Current source-of-truth rules:
README.mdis the end-user manualONBOARDING.mdis the contributor manualdocs/onboarding.mdincludesONBOARDING.mddocs/demos.mdincludesdemos/README.md
The README-driven site flow is:
npm run docs:preparerunsscripts/generate-docs-site.mjs- that script splits
README.mdinto chapter pages underdocs/generated/ - it also regenerates
docs/index.mdanddocs/.vitepress/generated-readme-nav.mjs vitepress build docsthen renders the static site from those generated files
Two design choices are important here:
- the chapter boundaries are driven by
##headings inREADME.md - the sidebar grouping inside
generate-docs-site.mjsis explicit on purpose, so a newly added or renamed chapter must be categorized instead of silently landing in the wrong place
That deliberate friction prevents documentation drift. If a contributor adds a new top-level manual chapter, the site generator should fail until the chapter is given an intentional place in the docs navigation.
npm run docs:build goes one step further: it builds the VitePress site and the standalone demo apps, then copies the demo outputs into the final static site.
When editing docs, keep the structure aligned with the code seams:
- runtime validation and transport export should be documented separately when their behavior differs
- recursive runtime support, recursive path behavior, and recursive transport export should be described explicitly rather than implied
- adapter pages should stay thin and should document how they route back into the shared schema engine instead of inventing second validation systems