@amritk/generate-examples
v0.8.6
Published
Generate fast-check arbitraries and example values from JSON Schemas.
Maintainers
Readme
@amritk/generate-examples
Programmatic API for generating fast-check arbitraries and example values from JSON Schemas.
Overview
@amritk/generate-examples turns a JSON Schema into test data. Where the
other mjst generators give you code that consumes data at runtime (parsers,
validators, types), this one closes the loop by giving you data to exercise
that code with.
Each generated file exports:
- A TypeScript
typedefinition for the schema - A
fast-checkarbitrary (FooArbitrary) that produces schema-valid values — ideal for property-based testing - A concrete, self-contained example value (
fooExample) — ideal for fixtures, seeds, and documentation
An index.ts barrel re-exports everything.
[!NOTE] The generated arbitraries import
fast-check, so consumers need it installed (npm i -D fast-check). An arbitrary whose schema uses a keyword nofc.*combinator captures on its own (if/then/else,not, exclusiveoneOf,contains, and the presence-gated object keywords —patternProperties,propertyNames,dependent*,min/maxProperties) also imports@amritk/runtime-validatorsfor a post-generation validating filter; files that need no such filter don't. The staticfooExamplevalues have no runtime dependencies.
@amritk/runtime-validatorsis adependencyhere rather than a peer, because this generator imports it itself. That resolves it for the generator, but not necessarily for the generated file — that file lands in your source tree, so under pnpm's strict layout or Yarn PnP it resolves from your project, not from this package's. If your schemas use any of those keywords, install it directly (npm i @amritk/runtime-validators). It cannot also be declared a peer: Bun rejects a workspace package listed as both, and--frozen-lockfilethen fails for the whole repo.
Installation
npm install @amritk/generate-examples
# or
pnpm add @amritk/generate-examples
# or
yarn add @amritk/generate-examples
# or
bun add @amritk/generate-examplesUsage
import { buildExampleSchema } from '@amritk/generate-examples'
const schema = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
age: { type: 'integer', minimum: 0 },
},
required: ['id'],
} as const
const files = await buildExampleSchema(schema, 'User')
// → [{ filename: 'user.ts', content: '...' }, { filename: 'index.ts', content: '...' }]The generated user.ts looks like:
import * as fc from 'fast-check'
export type User = { id: string; age?: number }
export const UserArbitrary: fc.Arbitrary<User> = fc.record(
{ "id": fc.uuid(), "age": fc.integer({ min: 0 }) },
{ requiredKeys: ["id"] },
)
export const userExample: User = { "id": "00000000-0000-0000-0000-000000000000", "age": 0 }Use the arbitrary in a property test:
import { test, fc } from '@fast-check/vitest'
import { UserArbitrary } from './generated'
import { parseUser } from './parsers'
test.prop([UserArbitrary])('parseUser round-trips any valid User', (user) => {
expect(parseUser(user)).toEqual(user)
})…or grab the static example as a fixture:
import { userExample } from './generated'
const res = await fetch('/users', { method: 'POST', body: JSON.stringify(userExample) })Lower-level API
| Export | Description |
|:---|:---|
| buildExampleSchema(schema, rootName, suffix?) | Walks the $ref graph and returns a GeneratedFile[] (one file per schema + an index.ts). |
| generateArbitrary(schema, typeName, suffix?, lazyRefFilenames?, rootSchema?) | Returns the export const …Arbitrary source for a single schema node. |
| generateExampleConst(schema, typeName, rootSchema?) | Returns the export const …Example source for a single schema node. |
| deriveExample(schema, rootSchema?) | Returns a concrete, schema-valid JavaScript value (no code-generation). |
| serializeValue(value) | Serializes a derived value to a TypeScript source expression (handles Date/bigint). |
Supported keywords
type — including multi-type unions like ['string', 'null'] —
(string/number/integer/boolean/null/array/object), properties,
required, items, minItems/maxItems, uniqueItems,
minLength/maxLength, pattern, format, minimum/maximum,
exclusiveMinimum/exclusiveMaximum, multipleOf, enum (filtered by sibling
constraints), const, minProperties/maxProperties, patternProperties,
propertyNames, dependentRequired, dependentSchemas, contains,
oneOf/anyOf, if/then/else, not, $ref, and the x-mjst extension
(Date, bigint). if/then/else, not, and oneOf exclusivity are
enforced by validating generated candidates against the schema and
retrying/rejecting. Unsupported constructs degrade to fc.anything() in
arbitraries and null in static examples.
Static examples cover every format @amritk/runtime-validators knows how to
check: email, idn-email, date, date-time, time, duration, uuid,
uri, iri, uri-reference, iri-reference, uri-template, json-pointer,
relative-json-pointer, hostname, idn-hostname, ipv4, ipv6, regex,
plus OpenAPI's url. An unrecognized format falls back to "string".
Known limits
Every fooExample is validated against its own schema before it is written. When
the value does not satisfy the schema it is still emitted — so the module always
compiles — but the generator prints a console.warn naming the type. Reach for
FooArbitrary in those cases: the arbitrary carries a runtime validating filter
and stays correct where the static value cannot.
The value falls short for three reasons:
- The schema has no instance.
{ pattern: '^ab$', minLength: 5 },uniqueItemsover booleans withminItems: 3, arequiredkey thatadditionalProperties: falseforbids, or aoneOfwhose branches every value matches twice. Nothing correct exists to emit; the warning is pointing at the schema, not the generator. - The constraint is beyond the deriver.
patternis sampled by a best-effort recursive-descent walk of the regex, so lookarounds and backreferences fall back to"string"; an unrecognizedformatdoes the same. - The bound is larger than any fixture should be. A derived string, array, or
object stops growing at 10,000 characters / elements / keys, so a document
asking for
minLength: 50000000yields a capped value and a warning rather than a 50 MB literal.FooArbitrarystill honours the real bound.
Two more shapes worth knowing about, both of which keep the generated file compiling rather than making it correct:
- A schema can require a key its generated type never declares —
requirednaming something absent fromproperties, adependentRequired/dependentSchemasdependency, or aminPropertiesfiller on an object with no index signature. The example keeps the key (a fixture missing what its schema demands is broken data) and is emitted as… as Foo, since a bare object literal with an excess property fails to compile. - An authored
defaultorexamples[0]is used only when it satisfies its own schema. A hint that does not ({ type: 'string', default: 42 }— common in documents whose field types changed after the hint was written) is ignored in favour of a structurally derived value, because the generated type follows the schema and would reject the hint outright.constis always honoured: the type is the const's own literal type, so the two cannot disagree. - An unsatisfiable range (
minLength: 10, maxLength: 2) collapses onto its upper bound in the arbitrary. Every boundedfc.*combinator assertsmin <= maxand throws at import, which would take down every other export in the file alongside it. Integer bounds are also confined to fast-check's own 32-bit range, and length/count bounds to non-negative integers. - A recursive definition's example has to stop somewhere and stops with
null, which the non-nullable type does not admit — so it is emitted as… as unknown as Node.NodeArbitraryties the recursion properly throughfc.letrecand needs no such escape. - A
patternthat is not a valid JavaScript regex, or that uses a lookahead or lookbehind, falls back to a plainfc.string().fc.stringMatchingcompiles the pattern at module scope and cannot generate from an assertion, so honouring it would throw where the whole file becomes unusable rather than just that one arbitrary being loose. - Nesting deeper than 400 levels is refused with an error naming the limit. Building an arbitrary costs several stack frames per schema level, so a deeper document exhausts the stack — the cap turns that into a message that says what is wrong.
Two shapes stay impossible to generate from, and the arbitrary will retry forever if you sample it. Both are schemas with no instance, and the example warns:
- A
patternno string of the required length can match ({ pattern: '^[a-z]{2}$', minLength: 5 }). A satisfiable-but-narrow pairing ({ pattern: '^[a-f0-9]+$', minLength: 32, maxLength: 32 }) is slow for the same reason —fc.stringMatchingrarely lands on the exact length. - A
minLength/minItemsso large that no value of that size can be built.
One more gap is not this package's to close: a $ref that resolves nowhere in
the document is typed by its name (Nope) but never imported, because it was
never generated as a file. The arbitrary degrades to fc.anything(), but the
type still names it, so the file does not compile. Same for { "type": [] },
which types as export type Foo = ;. Both come from
@amritk/helpers/generate-type-definition.
[!TIP] The example for a
$refis inlined by value, so a definition graph with wide fan-out produces a correspondingly large literal. That cost is in the output size, not in generation time — each definition is derived once per document.
