# fluent-json-schema and building JSON Schema by chaining calls instead of writing objects

> fluent-json-schema is a MIT licensed JavaScript library from the Fastify organisation that produces JSON Schema draft-07 documents through a chainable API, with type checking on the builder itself and a strict separation from validation. The interesting part is the runtime checking of the schema you are building, and the interesting limitation is that it stops at draft-07.

**fastify/fluent-json-schema** — A fluent API to generate JSON schemas

- Repository: https://github.com/fastify/fluent-json-schema
- Website: https://npmjs.com/package/fluent-json-schema
- Stars: 526 · Forks: 58
- Language: JavaScript
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/fastify-fluent-json-schema

## The problem: a schema is a second copy of your model

fluent-json-schema is described as a fluent API to generate JSON schemas, draft-07, for Node.js and browser, and framework agnostic. The problem it addresses is not that JSON Schema is hard to write. It is that a schema and the data it describes are two separate artefacts that drift apart.

The library's answer is a builder with a chainable surface. Each call returns a new schema object, so a schema is assembled the way a DOM is assembled or the way an SQL query is built, and the result is a plain object that any validator accepts.

The stated feature list is short and each entry answers a specific complaint. The fluent schema implements JSON Schema draft-07 standards. It is a faster and shorter way to write a JSON Schema via a fluent API, linked to the Fluent interface pattern. It gives runtime errors for invalid options or keywords misuse. JavaScript constants can be used in the JSON schema, for enum, const and default, avoiding discrepancies between model and schema. There are TypeScript definitions. And coverage is 100%.

That third feature is the one that separates this from a convenience wrapper. Most fluent builders produce a malformed document and let the validator complain later, at request time, in production. This one checks the builder calls themselves, so a bad keyword or a malformed option is an error where the mistake is, rather than a validation failure three layers away.

The fourth feature is the one with the clearest payoff, and it is worth spelling out because it is the reason to use this library rather than a hand-written schema file. If your roles are a JavaScript object, the enum values come from that object rather than from a second list you have to remember to update, so a new role cannot drift out of sync with the schema that describes it.

## What the builder actually produces

The usage example in the README is the clearest description of the API surface, and it is worth reading as a tour rather than as something to copy.

The entry point is a set of factory functions on the imported module: S.object(), S.string(), S.number(), S.null(), S.anyOf(), S.ref() and S.raw(). Each returns a schema fragment, and each fragment carries methods that produce a new fragment with one more constraint applied.

Metadata setters come first. The id method sets the $id, and title and description set the corresponding keywords. Then properties are added with prop, which takes a name and a schema.

Constraints are methods on the type-specific fragment rather than on a generic one. The email property uses S.string().format(S.FORMATS.EMAIL).required(), so format is a string method and required is a method available on any fragment. The password property uses S.string().minLength(8).required(). The role property uses S.string().enum(Object.values(ROLES)).default(ROLES.USER), which is the constants feature in use: the enum list and the default both come from the ROLES object.

The birthday property is where the escape hatch appears. It uses S.raw({ type: 'string', format: 'date', formatMaximum: '2020-01-01' }) with a comment noting that formatMaximum is an AJV custom keyword. So the fluent surface covers the standard keywords and anything else, including custom keywords that only a particular validator understands, goes through S.raw as a literal object.

Reusable shapes are handled by definition and ref. The address definition is built as a separate S.object() with its own id of #address, its own required list passed as an array, and a prop that refers back to it with S.ref('#address'). The result is a schema with a definitions section and a $ref property, which is what a validator expects.

## Install and first use, and what valueOf gives you

Installation is an ordinary npm package:

```bash
npm i fluent-json-schema
```

or with yarn:

```bash
yarn add fluent-json-schema
```

The library has a single runtime dependency, @fastify/deepmerge, and is published as CommonJS with the main entry at index.js and TypeScript definitions at types/index.d.ts. That single dependency is not incidental: merging is how the builder combines a base schema with the additions you make to it.

A first schema is a handful of chained calls:

```javascript
const S = require('fluent-json-schema')

const ROLES = {
  ADMIN: 'ADMIN',
  USER: 'USER',
}

const schema = S.object()
  .id('http://foo/user')
  .title('My First Fluent JSON Schema')
  .description('A simple user')
  .prop('email', S.string().format(S.FORMATS.EMAIL).required())
  .prop('password', S.string().minLength(8).required())
  .prop('role', S.string().enum(Object.values(ROLES)).default(ROLES.USER))
```

The last call matters: valueOf() returns the finished schema as a plain object. Nothing about the result is special, which is why the same document can be handed to AJV, to a Fastify route schema, or written to a file.

In the README's full example, that final line prints the schema with JSON.stringify(schema.valueOf(), undefined, 2), and the output shown in the documentation is the draft-07 document you would expect, with a $schema of http://json-schema.org/draft-07/schema#, the definitions section for address, the $id, the title, the description, the properties block and the required array listing email and password.

Note what the generated document does not contain. There is no $schema draft marker for 2020-12, and the required list on the nested address object is present while the top-level required list only includes the two fields marked required. The builder emits exactly what you asked for, which is a nice property but also means the shape of the output depends on how carefully you use it.

## Two ways to use null, and the difference between them

The address definition contains the detail most people copy from the README without thinking about, because it puts two different ways of expressing nullability next to each other.

The line1 property uses S.anyOf([S.string(), S.null()]), and the comment labels it as the JSON Schema way of expressing a nullable value. The line2 property uses S.string().raw({ nullable: true }), and the comment labels that as the Open API and Swagger way.

Both appear in the generated output and they are not interchangeable. The anyOf version produces a property whose type is a list of string and null, which is standard JSON Schema draft-07 and which any draft-07 validator understands. The nullable version produces a property of type string with a separate nullable keyword, which is not part of draft-07 at all and is a convention that AJV understands.

So the library does not pick one for you, and that is deliberate. The two forms exist because the two consumers exist: a JSON Schema validator wants anyOf, and an OpenAPI document wants nullable. Since the tool is framework agnostic and the README mentions both conventions side by side, choosing between them is part of what your schema is for.

There is a related consequence for how you validate. The README is explicit that fluent schema does not validate a JSON schema, and that many libraries can do that for you, with AJV given as the example and the AJV package shown as the install step. So the split is deliberate and complete: this library builds, AJV validates, and the boundary between them is documented rather than blurred.

## Validating with AJV, and reading the error output

The validation section is where the boundary becomes concrete, and the AJV examples are worth reading for what they show about the failure modes you will meet.

The setup is AJV configured with allErrors set to true, and then compiling the schema: ajv.compile(schema.valueOf()). The compile step returns a validation function.

The first example validates an empty object and the output lists two required-property errors, one for email and one for password. The second fills in a partially completed model with an email of test and a password of password, and the output is a single format error saying the email should match the format email.

That second example is the informative one, because it shows what the format keyword actually buys you. A string that is present and non-empty is not enough; the value has to look like an email address. A validation rule that only checks presence is trivially satisfied by a typo, and the format keyword is what catches it.

The third example uses a well-formed email and still fails, and the reason is instructive. The errors are three required-property failures pointing into the address definition, for country, city and zipcode. The nested object was never supplied, and the required list on the address definition is what makes the parent object invalid.

The final example is the only one that passes, printing valid true.

The pattern across all four is that the schema is doing structural work, and the nested required array is the piece that is easy to forget. The address definition declares required(['line1', 'country', 'city', 'zipcode']) while the top-level schema only requires email and password, so a user object with the two credential fields and no address is rejected even though nothing in the top-level required list mentions address. The schema is stricter than its visible required array suggests, because the reference pulls the nested requirements in.

## Extend, and the problem it solves with additionalProperties

The extend section addresses a specific problem with JSON Schema inheritance, and it is the feature most likely to matter to an API developer.

Normally, inheritance with JSON Schema is achieved with allOf. However, when additionalProperties(false) is used the validator will not understand which properties come from the base schema. S.extend creates a schema merging the base into the new one so that the validator knows all the properties because it evaluates only a single schema.

The reason this bites is the interaction between two features. A strict object schema with additionalProperties set to false is how you reject unknown fields, which is a reasonable thing to want in a request body. But when you compose two schemas with allOf, the strictness of the second one applies in isolation, and it sees the properties that came from the first as unknown. The result is a composed schema that rejects valid data.

S.extend sidesteps that by merging rather than composing. The base schema's properties are folded into the new one, and the validator evaluates a single document with one properties block and one required list. The cost is that the merged document no longer shows the inheritance structure in the output, which is a real difference for anyone whose tooling reads the schema shape.

The example in the documentation is a CRUD API: POST /users could use the userBaseSchema, while GET /users or PATCH /users use the userSchema, which contains the id and creation fields that the base does not have. The excerpt in the documentation is cut off mid-sentence there, so the exact call is not visible here, but the shape of the case is clear. A POST does not have an id yet, and a GET and a PATCH do, so the two routes need schemas that differ by a field, and additionalProperties(false) is what would otherwise break the composition.

The dependency list confirms the mechanism rather than suggesting it: @fastify/deepmerge is the only runtime dependency, so the merge is a deep merge of objects rather than a hand-rolled one.

## TypeScript, module formats and the project layout

The TypeScript section documents three import shapes, and the distinction between them is the practical detail.

With esModuleInterop set to true in the tsconfig.json, the import is a default import: import S from 'fluent-json-schema'. With esModuleInterop set to false, it is a namespace import: import * as S from 'fluent-json-schema'. And for native ESM modules, a named export is available: import { S } from 'fluent-json-schema'.

The reason there are three is the package manifest. The type field is commonjs and the main entry is index.js, so the CommonJS export shape determines how the module appears to a TypeScript compiler, and the named export exists so that native ESM consumers get a real named binding rather than the whole namespace.

The types directory is where the TypeScript definitions live, and the package points at types/index.d.ts. The tests are split in two, which is the interesting part of the scripts: test runs the unit tests and then the TypeScript tests, test:unit is c8 --100 node --test, and test:typescript is tstyche. That means the type definitions are checked by a dedicated tool rather than only by the compiler, which is how a library can ship declarations that are wrong in subtle ways and still pass its own build.

Coverage is enforced rather than reported, since the coverage flag is 100 in the unit test script and the feature list claims 100% coverage. The docs are generated rather than written, with the doc script running jsdoc2md over the source into docs/API.md, and the repository has a docs/ directory, an examples/ directory with an example.js file, a test/ directory, a types/ directory, an index.js and a lib/ directory.

The linting is neostandard through eslint, and the repository has an eslint.config.js, an .editorconfig, a .nvmrc and an .npmrc. The last two matter for a library like this: the .npmrc governs what gets published, and the .nvmrc pins the Node version the project develops against, so the version the maintainers test is visible rather than implied.

## Where fluent-json-schema is the wrong choice

Three limits are worth stating plainly.

The first is the draft. The library implements draft-07, and the generated document carries a $schema of http://json-schema.org/draft-07/schema#. JSON Schema has moved on since then, and the documentation says nothing about 2020-12 support, so a project that has standardised on the newer drafts or on OpenAPI 3.1 has to check what this library emits. The nullable example is the visible symptom: an OpenAPI-flavoured keyword inside a draft-07 document is a signal that the target dialect matters more here than it would in a plain schema-writing library.

The second is the size of the abstraction for a simple schema. If your schema is three properties with no references, writing the object literal is shorter than learning the builder, and the literal is what your validator reads with no translation step. The builder earns its keep when the schema has structure: definitions, references, shared constants, reuse across routes. Below that threshold it is overhead.

The third is that the runtime error checking, which is the library's main selling point, is also its main cost. Every builder call is checked, which means a schema built in a hot path is doing work the plain object does not. For a schema built once at module load that cost is irrelevant, but a library that generates schemas per request would be doing validation on every request, and the documentation does not discuss performance at all, so there is nothing to point at either way.

The alternative worth comparing is writing the JSON Schema directly, or using a type-first generator such as the ones that derive a schema from a TypeScript interface or a Zod definition. The difference in approach is where the source of truth lives: here it is the builder calls, and the schema is derived from them, while a type-first generator inverts that and derives the schema from a type definition you already have. The first keeps the schema and the model in one file, which is what the enum and const helpers are for. The second removes the duplication entirely but ties your schema to whichever type system that generator supports.

## Conclusion

fluent-json-schema is a good fit for a Node.js or browser codebase that already defines its data model as JavaScript constants and wants the schema to be derived from it rather than maintained twice, because the enum and const helpers exist for exactly that reason. It is a poor fit if you are on JSON Schema 2020-12, since the library targets draft-07 and does not mention the newer drafts. Before adopting, check that the keyword set you need exists in the API rather than only in the raw escape hatch, because S.raw is where anything unsupported ends up, and decide separately whether you want a builder library at all, since writing the schema object directly gives you a schema the validator understands without a second abstraction in between.

## FAQ

### What is fluent-json-schema?

It is a fluent API to generate JSON Schema documents, draft-07, for Node.js and browser, and it is framework agnostic. The library is MIT licensed, published as fluent-json-schema, and maintained under the Fastify organisation.

### How do I install fluent-json-schema?

With `npm i fluent-json-schema`, or `yarn add fluent-json-schema`. The package is CommonJS with TypeScript definitions at types/index.d.ts and has a single runtime dependency, @fastify/deepmerge.

### Does fluent-json-schema validate data?

No. The documentation is explicit that fluent schema does not validate a JSON schema, and points to AJV as one of the libraries that can, with the AJV package shown as the install step and ajv.compile(schema.valueOf()) as the compile call.

### How do I express a nullable property in fluent-json-schema?

Two ways, both shown in the README. S.anyOf([S.string(), S.null()]) is the JSON Schema way and produces a type list, while S.string().raw({ nullable: true }) is the Open API and Swagger way and produces a separate nullable keyword that AJV understands.

### What is S.extend for?

It creates a schema merging the base into the new one rather than composing them with allOf. That is needed because when additionalProperties(false) is used the validator will not understand which properties come from the base schema, so the merged document is evaluated as a single schema.

### Which JSON Schema draft does fluent-json-schema produce?

Draft-07. The generated document carries a $schema of http://json-schema.org/draft-07/schema#, and the documentation states that the fluent schema implements JSON Schema draft-07 standards.

## Sources

- [fastify/fluent-json-schema on GitHub](https://github.com/fastify/fluent-json-schema)
- [License: MIT](https://github.com/fastify/fluent-json-schema/blob/main/LICENSE)
- [Project website](https://npmjs.com/package/fluent-json-schema)
- [README](https://github.com/fastify/fluent-json-schema/blob/main/README.md)
- [Releases](https://github.com/fastify/fluent-json-schema/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/fastify-fluent-json-schema
