genever
v0.7.1
Published
Generate TypeScript types from JSON Schema files and OpenAPI documents, with strict schema checks and the same types Juniper infers.
Maintainers
Readme
genever
Generate TypeScript types from JSON Schema files and OpenAPI documents, with strict schema checks and the same types Juniper infers.
- Types that match the schema — tuples (
prefixItems),oneOfunions you can narrow,patternPropertieskeys like`x-${string}`, recursive$defsandif/then/elseall become precise types, notanyorunknown[]. - Round-trips Juniper — a schema built with Juniper, serialized and read back by genever, gives the same type Juniper inferred. Every release is tested against that.
- Strict by default — unknown keywords, keywords that don't apply to the schema's type, undeclared
requiredproperties, and contradictions likeminLengthovermaxLengthfail generation, instead of silently changing what the schema accepts. Without strict mode, the type follows exactly what a validator accepts. - One file from many —
$refs to other files and URLs are loaded and generated into the same output, with$idand$anchorresolved as the spec says. - Generated files stay in sync — a
--cimode fails when a committed file is out of date, like populate-files. - Types only — the output is plain TypeScript types, with no runtime dependency.
Guides and the full reference are on the docs site. For the motivation, and how it compares to other generators, see WHY-GENEVER.md.
Contents
- Install
- Example
- Usage
- Precise types
- How schemas become types
- Strict mode
- Stability of generated files
- API
- CLI
- Why "genever"?
- Compared to alternatives
- Also See
Install
npm i -D geneverESM only, and requires Node 24.22.1 or later (it fetches remote schemas, and earlier releases have security flaws in fetch). It is a dev dependency: the output is plain TypeScript with no runtime import of genever.
Example
user.json:
{
"type": "object",
"properties": {
"id": { "type": "string", "pattern": "^usr_" },
"home": { "$ref": "#/$defs/Address" },
"point": {
"type": "array",
"prefixItems": [{ "type": "number" }, { "type": "number" }],
"items": false,
"minItems": 2
}
},
"required": ["id"],
"$defs": {
"Address": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
}npx genever user.json --name Useruser.ts:
// Generated by genever. Do not edit by hand, regenerate it instead.
export type User = {
/**
* @pattern "^usr_"
*/
id: `usr_${string}`;
home?: Address;
/**
* @minItems 2
*/
point?: [number, number];
};
export type Address = {
city: string;
};Usage
genever is an ESM module, and a CLI. Most projects run the CLI as a build step, and commit the generated file. In CI, --ci (on by default when the CI environment variable is set) fails instead of writing when the file is out of date.
The programmatic API is the same: genever(schema) returns the TypeScript source, and geneverFile(path) reads a schema file and writes the types next to it.
The root schema becomes a type named Schema (or --name). Every definition in the root $defs (or draft 7 definitions) becomes an exported type named after its key, in PascalCase. A schema referenced by $ref from elsewhere in the document becomes a named type too, named after its key in any nested $defs, or else after its path. Everything else is inlined.
References to other files
A $ref may point at another file, or a URL, relative to the schema it is in: { "$ref": "./address.json" }, { "$ref": "https://example.com/schemas/user.json#/$defs/Name" }. The CLI and geneverFile load each one, and everything it references in turn, and the output is still one file:
- In an OpenAPI document, any schema may: a component, or a schema in an operation's parameters, request body or responses.
- An OpenAPI document may be split across files. A reference object (a path item, parameter, request body, response or header that is only a
$ref) may point at another file, such as./paths/pets.yamlor./responses.yaml#/NotFound, and a schema may point into another OpenAPI document, such as./common.yaml#/components/schemas/Error. Of another document, only the schemas used become types, named as in the input (Error). Its schemas keep its own OpenAPI version, and a file of OpenAPI objects without one has the version of the document that refers to it. - Each referenced file becomes a type named after its
title, or else its file name (address.jsonisAddress). Two of the same name are numbered (Address2). - A file referenced twice is one type.
- A file that only holds
$defs(shared definitions) is not a type itself, but its definitions are, when referenced. - Files without a
$schemaare read in the input's dialect: JSON Schema 2020-12, or the OpenAPI version of an OpenAPI document. - A file or URL that can't be loaded throws a
GeneverLoadError.
Loaded files are embedded with an absolute $id, following the bundling process of JSON Schema 2020-12, so $refs resolve to them unchanged, and $id and $anchor inside them work as in the original files. A relative $ref in a URL resolves against where it redirected to, if it did. --no-fetch (or fetch: false) loads files only, never URLs: a $ref to a URL fails with a GeneverLoadError, unless a loaded schema declares that URL as its $id, such as a copy committed next to the input. --fetch-host example.com (or fetch: ['example.com']) loads only URLs of the hosts listed, and only follows redirects to them. A host matches on any port unless it names one (localhost:8080), and *.example.com matches subdomains of example.com, but not example.com itself. genever() itself reads one document, and doesn't load anything: bundle first, or use geneverFile.
Checking without fetching
--ci regenerates the file, so it fetches every URL again, and fails when a remote schema changed even though nothing in the repository did. With --manifest generated.json (or manifest), genever also records the output in a populate-files manifest, with hashes of everything it read, its own version, and the options. --ci with the same manifest then passes without loading anything if all of these hold: the version and options match, the output is unedited, and every file it read is unchanged. URLs are trusted as recorded. Otherwise it regenerates as usual, and fails with what is out of date. Run genever without --ci to pick up remote changes, which then show up as a diff in review.
npx genever schema.json --manifest generated.json # writes schema.ts and generated.json
npx genever schema.json --manifest generated.json --ci # in CI: no network neededOpenAPI documents
An OpenAPI 3.0 or 3.1 document (a JSON or YAML file with an openapi version) is read too. Each of its components.schemas becomes an exported type named after its key, and there is no root type. The version decides the dialect: 3.0 schemas may use nullable, and 3.1 schemas are JSON Schema 2020-12. Either may use OpenAPI's own keywords (discriminator, xml, externalDocs, example), which don't change the type.
npx genever openapi.yaml -o src/api-types.tsA document with paths also gets Paths (by path, then method) and Operations (by operationId) types, in the shape openapi-typescript generates: each operation's parameters by location, requestBody, and responses by status code with headers and content by media type.
type User = Paths['/users/{id}']['get']['responses'][200]['content']['application/json'];
type NewUser = Operations['createUser']['requestBody']['content']['application/json'];Two generic types, keyed by operationId, put an operation's types in the shape a typed client or server passes around. A misspelled operationId is a type error:
// { readonly body: NewUser } & { readonly path: { orgId: string } }: each part only if the operation has it
type Create = OperationInput<'createUser'>;
// { readonly status: 201; readonly body: User } | { readonly status: 400; readonly body: ApiError }
type Created = OperationResult<'createUser'>;On a server, a validator that fills in defaults (Ajv's useDefaults) leaves a parameter whose schema has a default always there, though OperationInput has it optional, for callers. With --parameter-defaults, genever also writes OperationRequest<Id>, the handler's view: as OperationInput, but with those parameters required. Request bodies are left as sent.
YAML
A .yaml or .yml file is read as YAML 1.2, whether it is the input or a file it references, and so is a URL served with a YAML media type. Anchors and aliases are expanded, and merge keys (<<: *base) merged. An alias inside the schema it names, a recursive schema, becomes a $ref to that schema, so it is a recursive type. YAML 1.2 reads on, off, yes and no as strings, not booleans. Anything else JSON can't hold is an error: tags beyond YAML's core schema (such as !!binary), keys that aren't strings, duplicate keys and more than one document throw the yaml package's YAMLError (invalid JSON throws a SyntaxError), and .inf, .nan and an alias inside a value that isn't a schema (such as a const) throw a GeneverInvalidSchemaError. In a referenced file, any of these is a GeneverLoadError.
The generated file is formatted with format-file, using your project's Biome or Prettier config if either is installed.
Precise types
Where other generators commonly lose type information, genever keeps it:
| Schema | genever | Often generated instead |
|---|---|---|
| prefixItems: [{ number }, { number }], items: false | [number, number] | never[], any[], or [number, number, ...number[]] |
| oneOf of objects with a kind const | a union that narrows on kind | one object, with each branch's properties optional, or any |
| pattern: "^usr_" | `usr_${string}` | string |
| patternProperties: { "^x-": string }, additionalProperties: false | { [key: `x-${string}`]: string } | { [k: string]: unknown }, or string for every key |
| recursive $defs | recursive named types | any after a few levels, or undefined |
| enum: ["a", 42, null] | "a" \| 42 \| null | string \| number \| null |
Each row is one of the probes in WHY-GENEVER.md, which compares genever with five other tools on the same schema.
How schemas become types
| JSON Schema | TypeScript |
|---|---|
| true, {} | unknown |
| false, { "not": {} } | never |
| type | string, number (also integer), boolean, null; a type array is a union |
| pattern of a literal prefix, suffix or substring (^abc, abc$, abc) | `abc${string}`, `${string}abc`, `${string}abc${string}` |
| any other pattern, format, lengths, ranges | the plain type, as TypeScript can't express them |
| enum, const | literal types, including objects and arrays |
| items | T[] |
| items + minItems up to 3 | [T, ...T[]], with minItems leading items. Larger values stay T[] (see typedMinItems) |
| prefixItems + items: false | [A, B], with items beyond minItems optional |
| prefixItems + items | [A, B, ...T[]] |
| draft 7 items array + additionalItems | same as prefixItems + items |
| properties + required | { a: A; b?: B } |
| additionalProperties: true / schema | [key: string]: unknown / T |
| patternProperties | an index signature, keyed by `x-${string}` for a literal prefix like ^x-, or string otherwise |
| type: "object" with no properties | Record<string, unknown> |
| dependentRequired, dependentSchemas, draft 7 dependencies | { a?: never } \| { b: B } |
| allOf | intersection |
| anyOf, oneOf | union |
| if / then / else | (If & Then) \| Else |
| not of null or literal values | removes them from a union; any other not leaves the type |
| $ref | the referenced named type. It resolves against the nearest $id, so #/$defs/A inside an embedded resource is that resource's A, and may name an $anchor |
| draft-04 id, and boolean exclusiveMinimum/exclusiveMaximum | in a schema whose $schema is draft-04, or inside one: id names a resource as $id does, and the booleans make minimum and maximum exclusive. Elsewhere, id is an unknown keyword |
| propertyNames with a literal prefix or enum | the keys of additionalProperties: `x-${string}`, or optional properties a, b |
| title, description, deprecated | JSDoc |
| default, examples, format, and constraints such as minLength or maximum | JSDoc tags, e.g. @minLength 3, so editors show them |
Subschemas of allOf, anyOf, oneOf, if/then/else and not without a type of their own apply to their parent's types, as Juniper writes them. So { "type": ["string", "null"], "allOf": [{ "pattern": "^a" }] } is `a${string}` | null.
Without additionalProperties, an object type has no index signature: TypeScript objects already allow other keys, and reading an undeclared key stays an error.
Not supported
$dynamicRefand$recursiveRefthrowGeneverUnsupportedError.genever()only reads one document, so with it, a$refto another file does too (see References to other files). References may use$idand$anchor.unevaluatedProperties,unevaluatedItemsandcontainsare accepted, but don't change the type.discriminatoris accepted in plain JSON Schema too, since Juniper and Ajv use it there, and doesn't change the type: the branches narrow on the property as a union does. Strict mode checks it can tell the branches apart: the property must be required, by the schema or by everyoneOf/anyOfbranch, and everymappingtarget must exist. Its values aren't checked, as OpenAPI may take them from component names.- Swagger 2.0 documents.
- An OpenAPI document's
webhooksand operations'callbackshave no types, though their schemas incomponentsdo.
Differences from Juniper
genever is tested to generate the same type Juniper infers, for Juniper schemas covering each of its schema types and keywords. The exceptions are where the JSON says more than Juniper's type does, or less:
- An object schema with no properties is
Record<string, unknown>, not Juniper's empty object type. - Prefix items beyond
minItemsare optional. Juniper'stupleSchema()always setsminItems, so its tuples are identical. - In
if/then/else, a property set tofalseinelseis?: never. - A type from
.cast<T>()orcustomSchema<T>()exists only in TypeScript, so genever reads the JSON it emits instead.
Strict mode
Strict mode (the default) fails with a GeneverStrictModeError on schemas that are valid JSON Schema, but most likely mistakes. Validators ignore these, so the schema silently accepts more, or less, than it appears to.
Keywords that do nothing:
- An unknown keyword. Keywords starting with
x-are allowed, as are OpenAPI's keywords in an OpenAPI document or withopenApi30. - A type specific keyword (e.g.
maxLength) in a schema without atype, or whosetypeit doesn't apply to (e.g.maxLengthon a number). Subschemas inherit their parent's type. - A
requiredproperty that isn't inproperties, of the schema or a parent. thenorelsewithoutif.
Schemas that accept nothing, or less than they declare:
minLengthgreater thanmaxLength, and the same forminItems/maxItems,minProperties/maxPropertiesandminContains/maxContains.minimum/exclusiveMinimumandmaximum/exclusiveMaximumthat no number (or no integer, forinteger) fits between.- An
enumorconstvalue that isn't of the schema'stype. - A
requiredproperty whose schema is never valid (e.g.false), or more required properties thanmaxPropertiesallows. minPropertiesmore than an object withadditionalProperties: falsecan have.- A tuple (
prefixItemswithitems: false) shorter thanminItems, or longer thanmaxItems. - A
$refback to a schema it is part of, without a property or item in between, such as{ "anyOf": [{ "$ref": "#" }, { "type": "string" }] }. It applies to the same value again, so a validator never finishes checking it, and JSON Schema leaves its behaviour undefined.
Every schema Juniper builds passes.
Without strict mode
Disable strict mode (strict: false, --no-strict) for schemas you don't control. The generated type then follows what a validator accepts, and is never narrower:
- Unknown keywords, and keywords that don't apply to the type, are ignored.
- A schema without a
typeaccepts every type.{ "properties": { "a": … } }is the object type, or any string, number, boolean,nullor array. - A type whose constraints contradict each other is
never. For{ "type": ["string", "null"], "minLength": 3, "maxLength": 2 }, onlynullis valid. - Tuple items beyond
maxItemsare left out. - A
$refback to a schema it is part of, without a property or item in between, isnever: it can't be why a value is valid. TheanyOfabove isstring. A schema it passes through on the way back is inlined, as TypeScript rejects a type that refers to itself outside an object or array.
Stability of generated files
The text of a generated file is not part of genever's API. Formatting, ordering, and how a type is written (e.g. Record<string, number> or { [key: string]: number }) may change in any release.
The types TypeScript reads from it are. A change that makes a generated type accept or reject different values, or read differently (such as a union becoming an intersection), is a breaking change.
API
genever(schema, options?)
Generate TypeScript types from a JSON Schema.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| schema | unknown | — | Required. Parsed JSON Schema, or OpenAPI document. |
| options.name | string | 'Schema' | Name of the root schema's type. Not used for an OpenAPI document. |
| options.strict | boolean | true | Fail on likely mistakes. See Strict mode. |
| options.baseUri | string | a placeholder | URI the schema was read from, which relative $ids and $refs resolve against. |
| options.parameterDefaults | boolean | false | With an OpenAPI document, also generate OperationRequest<Id>: what a handler gets once a validator that fills in defaults has run. |
| options.typedMinItems | number | 3 | Largest minItems typed as required items, e.g. [T, ...T[]]. 0 turns it off. The default matches Juniper. |
| options.openApi30 | boolean | false | Read the schema as an OpenAPI 3.0 schema, which allows nullable and example. An OpenAPI document's own version is used instead. |
Returns string — TypeScript source, not formatted.
Throws GeneverError — see Errors.
import { genever } from 'genever';
genever({ type: 'array', items: { type: 'string' } }, { name: 'Tags' });
// export type Tags = string[];geneverFile(input, options?)
Read a JSON Schema file, and write its types to a TypeScript file.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| input | string | — | Required. Path to the JSON Schema file. |
| options.output | string | input path with .ts instead of .json | Path of the TypeScript file to write. |
| options.cwd | string \| URL | process.cwd() | Directory relative paths resolve from. |
| options.check | boolean | false | Fail, rather than write, if the output file is not up to date. |
| options.dryRun | boolean | false | Do not write the output file. |
| options.signed | boolean | false | Sign the output file with generated-signature, so edits to it are detected. An output file that was edited, or is not signed yet, is not overwritten (a PopulateFilesOwnershipError from populate-files). |
| options.force | boolean | false | With signed, overwrite the output file anyway. Use it once to start signing an existing file. |
| options.manifest | string | — | Path of a manifest (relative to cwd) to record the output and hashes of its inputs in. With check, an output matching its entry is up to date without loading anything. See Checking without fetching. |
| options.fetch | boolean \| string[] | true | Load http: and https: URLs the schema references, or only those of the hosts listed. Otherwise only files are loaded. See References to other files. |
| options.name, options.strict, options.openApi30, options.parameterDefaults, options.typedMinItems | | | Same as genever. |
Returns Promise<{ output: string; updated: boolean }> — absolute path of the output file, and whether its content changed.
Throws GeneverError — see Errors. With check, also throws a PopulateFilesCheckError (from populate-files, ... not up to date. Reason: content-changed) if the file is out of date. Rejects with the file system's error (ENOENT) if input doesn't exist, and a SyntaxError for invalid JSON. Throws a TypeError if fetch lists something that is not a host.
Errors
Every error extends GeneverError, with a pointer property: the JSON Pointer to the schema or keyword at fault, e.g. #/properties/a/type. In another OpenAPI document, or file of OpenAPI objects, it starts with that file's URI, e.g. file:///api/common.yaml#/components/schemas/Error/type. The pointer is also at the end of the message.
| Error | Thrown when |
|-------|-------------|
| GeneverInvalidSchemaError | The input is not a valid JSON Schema, e.g. properties is not an object, or a $ref does not resolve. |
| GeneverStrictModeError | A strict mode rule is broken. |
| GeneverUnsupportedError | The schema uses a feature genever can't generate types for. See Not supported. |
| GeneverLoadError | A file or URL the schema references could not be loaded. Its pointer is to the $ref that needed it, in the file or URL its referencedFrom names: the input, or a file the input loaded. Its cause is the underlying error, such as the file system's or the parser's, if there was one. |
CLI
genever <input> [options]| Option | Default | Description |
|--------|---------|-------------|
| -o, --output | input path with .ts | Path of the TypeScript file to write. |
| --name | Schema | Name of the root schema's type. Not used for an OpenAPI document. |
| --strict | true | Fail on likely mistakes. Disable with --no-strict. |
| --parameter-defaults | false | With an OpenAPI document, also write OperationRequest, with parameters' defaults filled in. |
| --typed-min-items | 3 | Largest minItems typed as required items. 0 turns it off. |
| --open-api-30 | false | Read the schema as an OpenAPI 3.0 schema. An OpenAPI document's own version is used instead. |
| --ci | true in CI | Fail if the output file is not up to date. Implies --dry-run. |
| --dry-run | false | Do not write the output file. |
| --signed | false | Sign the output file, so edits to it are detected, and refuse to overwrite it if it was edited or is not signed. |
| --force | false | With --signed, overwrite the output file anyway. |
| --manifest | | Record the output, and hashes of its inputs, in this manifest. With --ci, an output that still matches it passes without fetching URLs. |
| --fetch | true | Load URLs the schema references. --no-fetch loads only files. |
| --fetch-host | | Load only URLs of this host, e.g. example.com, localhost:8080, or *.example.com for its subdomains. Repeat for more hosts. |
| --cwd | . | Directory relative paths resolve from. |
The path of a written file is logged. Nothing is logged when it was already up to date. Any error, including a failed --ci check, prints its stack trace and exits with code 1.
Why "genever"?
Juniper builds schemas with their types. Juniper berries are what give gin its flavor, and genever is gin's Dutch ancestor: genever takes what Juniper made, and gets its types back.
Compared to alternatives
- json-schema-to-typescript — the most used generator. 2020-12 tuples become
never[],patternis alwaysstring, andpatternPropertiesopen the object to[k: string]: unknown. - quicktype — many languages, but merges
oneOfobjects into one type with optional fields, and widensenumnumbers. - json-schema-to-zod — generates a validator, but every
$refbecomesz.any(). - json-schema-to-ts, TypeBox
Static— infer types in TypeScript instead of generating them, so the schema has to be pasted into TypeScriptas const. An imported.jsonfile widens tounknown.
Tested side by side in WHY-GENEVER.md.
Also See
- juniper — build JSON Schemas in TypeScript, with their types. genever reads them back.
- populate-files — writes genever's output, and checks it in CI.
