juniper
v1.2.2
Published
ESM JSON Schema builder for static Typescript inference.
Downloads
562
Maintainers
Readme
Juniper
ESM JSON Schema builder for static Typescript inference
Contents
- Introduction
- Install
- Example
- Usage
- Schemas
- API
- Recipes
- Motivation (The JSON Schema Problem)
- Objectives
- Limitations
- Comparisons
Introduction
Juniper is a JSON Schema generator that focuses on solving two problems:
- Writing strict and maintainable JSON Schemas.
- Using those enforced schemas as Typescript interfaces.
Juniper primarily supports Draft 2020-12 JSON Schema, but also supports OpenAPI 3.0 as much as possible.
Juniper does not provide any JSON Schema validation. Please use a validation library such as Ajv for any validation. All examples in this documentation will use Ajv.
Install
npm i juniper
Example
import Ajv from 'ajv/dist/2020.js';
import { SchemaType, stringSchema, objectSchema } from 'juniper';
const schema = objectSchema({
properties: {
foo: stringSchema({
maxLength: 10,
}).startsWith('abc'),
bar: stringSchema().nullable(),
anything: true,
},
required: ['foo'],
additionalProperties: false,
});
/**
* {
* foo: `abc${string}`;
* bar?: string | null;
* anything?: unknown;
* }
*/
type ISchema = SchemaType<typeof schema>;
/**
* {
* type: 'object',
* properties: {
* foo: {
* type: 'string',
* maxLength: 10,
* pattern: '^abc',
* },
* bar: {
* type: ['string', 'null'],
* },
* anything: true,
* },
* required: ['foo'],
* additionalProperties: false,
* }
*/
const jsonSchema = schema.toJSON();
const validator = new Ajv().compile<ISchema>(jsonSchema);
const unknownUserInput: unknown = getUserInput();
if (validator(unknownUserInput)) {
console.log(unknownUserInput.foo); // abc123
}
Usage
Juniper is an ESM module. That means it must be import
ed. To load from a CJS module, use dynamic import const { stringSchema } = await import('juniper');
.
For every schema exported, there is both a class and functional constructor for each schema. The class can be instantiated directly via the new
keyword, or via the static create
method. The functional constructor is a reference to the create
method. All three are perfectly valid ways of creating a schema, and entirely up to you to prefer OOP vs functional programming styles.
The instance returned by all three methods will be an instance of that schema class.
import { StringSchema, stringSchema } from 'juniper';
// All methods are logically the same
const schema1 = new StringSchema({ maxLength: 10 });
const schema2 = StringSchema.create({ maxLength: 10 });
const schema3 = stringSchema({ maxLength: 10 })
console.log(schema3 instanceof StringSchema); // true
Juniper instances are immutable. That means calling an instance method does not alter the existing instance, and has no side effects. Every method that "alters" the schema will return a clone of the instance.
import { numberSchema } from 'juniper';
const schema1 = numberSchema({ type: 'integer' });
const schema2 = schema1.multipleOf(5);
console.log(schema1 === schema2); // false
console.log(schema1.toJSON()); // { type: 'integer' }
console.log(schema2.toJSON()); // { type: 'integer', multipleOf: 5 }
It is highly recommended the Juniper module is used with Typescript. While it may provide some benefits in a JS or loosely typed environment for maintaining schemas, a significant portion of the logic resolves around generating correct types associated with each schema.
There are also many validation restrictions that are only applied in Typescript. For example:
import { booleanSchema, objectSchema } from 'juniper';
const bool = booleanSchema().anyOf([
booleanSchema({ description: 'provides no extra benefit' })
]);
const obj = objectSchema({
properties: {
foo: 123,
},
// FOO does not exist in properties
required: ['FOO'],
}).properties({
// already assigned above
foo: booleanSchema(),
}).oneOf([
// An object cannot also be a boolean
bool
]);
The above code will fail Typescript validation for a number of reasons. However it will not necessarily fail when executed as plain Javascript. The resulting JSON Schema may be equally nonsensical.
Schemas
Juniper exports the following Schema classes, with their provided JSON Schema/Typescript equivalent.
Juniper Class | JSON Schema | Typescript literal
---|---|---
ArraySchema | type: 'array'
| unknown[]
BooleanSchema | type: 'boolean'
| boolean
CustomSchema | N/A (whatever is provided) | N/A (whatever is provided)
EnumSchema | enum: []
| N/A (Union \|
of provided literals)
MergeSchema | N/A (compositional schema) | initially unknown
then \|
or &
as appropriate.
NeverSchema | not: {}
| never
NullSchema | type: 'null'
| null
NumberSchema | type: 'number'
OR type: 'integer'
| number
ObjectSchema | type: 'object'
| {}
StringSchema | type: 'string'
| string
TupleSchema | type: 'array'
| [unknown]
Schemas come with a couple caveats:
NumberSchema
can emit type ofinteger
andnumber
(default), based on thetype
field. It does not impact TS typings.MergeSchema
without "merging" anything can be used as a genericunknown
. Using methods likeallOf
andanyOf
can generate a mix of unrelated types likenumber | string
.TupleSchema
is a convenience wrapper aroundArraySchema
ensuring "strict" tuples. The same functionality can be achieved via rawArraySchema
.CustomSchema
is used to break out of the Juniper environment. It's usage is discouraged, but may be the best solution when dealing with instances where some JSON Schemas + typings already exist, and for gradual adoption of Juniper.- There is no
any
schema, asany
is discouraged in favor ofunknown
(MergeSchema
). Ifany
is truly required,CustomSchema
may be used (default output is "always valid" empty JSON Schema)
API
Helper Types
Type | Interface | Description
---|---|---
SchemaType | SchemaType<Schema>
| SchemaType<JSON>
| Extracts the TS type from the class or JSON.
Schema | Schema<number>
| Juniper Schema that describes a typescript interface. Only usable for passing to rendering to JSON and occasionally as a parameter to other Juniper instances.
JSONSchema | JSONSchema<number>
| JSON Schema object that describes the specified Typescript type
EmptyObject | EmptyObject
| Describes an actually empty object. Mostly used internally but exposed for convenience.
PatternProperties | PatternProperties<`abc${string}`>
| Describes a string pattern type. See ObjectSchema.patternProperties
for usage.
Constructors
Every schema can be generated three ways:
new
Keyword -new StringSchema()
- static
create
method -StringSchema.create()
- functional constructor -
stringSchema()
Every constructor takes a single options object, to set properties on the JSON object. Every parameter is optional, and can also be set via a method of similar name.
stringSchema({ maxLength: 5 })
== StringSchema.create().maxLength(5)
== new StringSchema({}).maxLength(5)
.
Not every property can be set in the constructor, and must be set via a method. This limitation is usually due to restrictions of type inference on the constructor alone.
Schema Constructors make heavy use of Typescript Generics. The usage of these generics should be seen as internal, and may break in unannounced ways in future releases. The one exception is CustomSchema
, whose type is provided via the Generic parameter.
Juniper instances are immutable, so every method returns a clone of the original instance, with the provided changes.
Generic Schema helper methods
The following helper methods are provided for typing/exporting the JSON Schema from a Juniper instance:
toJSON
- Renders the JSON Schema document. Document should be immediately passed to a validator or serializer. The exact structure of the document is not guaranteed, and should not be modified further.
- Options
openApi30
-boolean
- Output a JSON Schema compliant with OpenAPI 3.0. Not every property is fully supported! See implementation warnings.id
-string
optionally provide a value to be placed in the$id
field of the document.schema
-boolean
Include the draft as the$schema
property.
ref
- Returns a schema object (which can be modified further) that extends the schema via the
$ref
property. - Parameters
path
-string
Path to where schema is actually stored in document. Final document structure is implementation specific and not verifiable.
- If referencing a JSON Schema entirely out of control, it is best to use the
ref
method on aCustomSchema
. Otherwise it is designed for instances where common schemas are pulled into a reusable section, such as OpenApi'scomponents
section. - Example:
Note the above example is not 100% compliant with OpenAPI spec. Theimport { stringSchema, objectSchema } from 'juniper'; // string const idSchema = stringSchema({ title: 'Custom ID', pattern: '^[a-z]{32}$', }); // { id: string } | null const resourceSchema = objectSchema({ properties: { id: idSchema.ref('#/components/schemas/id') }, required: ['id'], }); const nullableResourceSchema = resourceSchema.ref('#/components/schemas/id').nullable(); console.log({ components: { schemas: { id: idSchema.toJSON({ openApi30: true }), resource: resourceSchema.toJSON({ openApi30: true }), nullableResource: nullableResourceSchema.toJSON({ openApi30: true }), }, }, }); /** * { * components: { * schemas: { * id: { * type: 'string', * title: 'Custom ID', * pattern: '^[a-z]{32}$' * }, * resource: { * type: 'object', * properties: { * id: { $ref: '#/components/schemas/id' } * }, * required: ['id'], * }, * nullableResource: { * $ref: '#/components/schemas/resource', * nullable: true * }, * } * } * } */
nullableResource
is merged with the$ref
(allowed in Draft 2020-12 and generally supported by most resolvers). Full compliance could be achieved by manually merging with a NullSchema:
The resulting types are identical.const nullableResourceSchema = mergeSchema().oneOf([ resourceSchema, nullSchema ]);
- Returns a schema object (which can be modified further) that extends the schema via the
cast
- Casts the instance as a schema for a specific type. Use with caution as it can only be further chained with a
toJSON
call. Possibly useful when declaring a schema for javascript-generated objects that are not explicitly enforced in JSON Schema. - Example:
import { objectSchema } from 'juniper'; const kindSym = Symbol.for('kind'); const userSchema = objectSchema({ properties: { id: true, email: true; }, additionalProperties: false, }).cast<{ [kindSym]: 'user'; id: string; email: string; }>();
- Casts the instance as a schema for a specific type. Use with caution as it can only be further chained with a
metadata
- Allows attaching any custom data to a JSON Schema. For example,
x-
prefix for OpenAPI. - Only restriction is keys cannot overlap with existing implementations.
numberSchema().metadata('maximum', 5)
is forbidden. - Parameters
- Either as
(key, value)
format, or({ key1: val1, key2: val2 })
format.
- Either as
- Allows attaching any custom data to a JSON Schema. For example,
Individual schemas may not expose every method, generally due to the result being nonsensical, or forbidden (e.g. enum: []
cannot also be nullable
).
Generic Schema Methods/Properties
The following methods are available on every Schema:
Method Name | Constructor Parameter | Can be Unset | Changes Types ---|:---:|:---:|:---: title | ✅ | ✅ | ❌ description | ✅ | ✅ | ❌ default | ✅ | ✅ | ❌ deprecated | ✅ | ✅ | ❌ deprecated | ✅ | ✅ | ❌ example(s) | ❌ | ❌ | ❌ readOnly | ✅ | ✅ | ❌ writeOnly | ✅ | ✅ | ❌ allOf | ❌ | ❌ | ✅ anyOf | ❌ | ❌ | ✅ oneOf | ❌ | ❌ | ✅ not | ❌ | ❌ | ✅ if then else | ❌ | ❌ | ✅ nullable | ❌ | ❌ | ✅
Specific Schema Methods
Schema | Method Name | Constructor Parameter | Can be Unset | Changes Types | OpenAPI 3.0 Support ---|---|:---:|:---:|:---:|:---: ArraySchema | items | ✅ | ❌ | ✅ | ✅ ArraySchema | maxItems | ✅ | ✅ | ❌ | ✅ ArraySchema | minItems | ✅ | ✅ | ❌ | ✅ ArraySchema | uniqueItems | ✅ | ✅ | ❌ | ✅ ArraySchema | contains | ❌ | ❌ | ✅ | ❌ ArraySchema | maxContains | ✅ | ✅ | ❌ | ❌ ArraySchema | minContains | ✅ | ✅ | ❌ | ❌ ArraySchema | (prepend)prefixItem | ❌ | ❌ | ✅ | ❌ EnumSchema | enum(s) | ✅ | ❌ | ✅ | ✅ NumberSchema | type | ✅ | ✅ | ❌ | ✅ NumberSchema | multipleOf | ✅ | ❌ | ❌ | ✅ NumberSchema | maximum | ✅ | ✅ | ❌ | ✅ NumberSchema | exclusiveMaximum | ✅ | ✅ | ❌ | ✅ NumberSchema | minimum | ✅ | ✅ | ❌ | ✅ NumberSchema | exclusiveMinimum | ✅ | ✅ | ❌ | ✅ ObjectSchema | properties | ✅ | ✅ | ✅ | ✅ ObjectSchema | maxProperties | ✅ | ✅ | ❌ | ✅ ObjectSchema | minProperties | ✅ | ✅ | ❌ | ✅ ObjectSchema | required | ✅ | ✅ | ✅ | ✅ ObjectSchema | additionalProperties | ✅ | ✅ | ✅ | ✅ ObjectSchema | patternProperties | ❌ | ✅ | ✅ | ❌ ObjectSchema | dependentRequired | ❌ | ❌ | ✅ | ✅ ObjectSchema | dependentSchemas | ❌ | ❌ | ✅ | ✅ ObjectSchema | unevaluatedProperties | ✅ | ❌ | ❌ | ❌ StringSchema | format | ✅ | ✅ | ❌ | ✅ StringSchema | maxLength | ✅ | ✅ | ❌ | ✅ StringSchema | minLength | ✅ | ✅ | ❌ | ✅ StringSchema | pattern | ✅ | ❌ | ❌ | ✅ StringSchema | startsWith | ❌ | ❌ | ✅ | ✅ StringSchema | endsWith | ❌ | ❌ | ✅ | ✅ StringSchema | contains | ❌ | ❌ | ✅ | ✅ StringSchema | contentEncoding | ✅ | ✅ | ❌ | ✅ StringSchema | contentMediaType | ✅ | ✅ | ❌ | ✅ TupleSchema | uniqueItems | ✅ | ✅ | ❌ | ✅ TupleSchema | contains | ❌ | ❌ | ✅ | ❌ TupleSchema | maxContains | ✅ | ✅ | ❌ | ❌ TupleSchema | minContains | ✅ | ✅ | ❌ | ❌ TupleSchema | (prepend)prefixItem | ❌ | ❌ | ✅ | ❌
Implementation Notes
- ObjectSchema.patternProperties takes advantage of the string type of the keys. However the key itself is a regular expression pattern, and cannot be interpreted directly. So the key should be wrapped with the
PatternProperties
helper type.- Example:
import { numberSchema, objectSchema, PatternProperties, SchemaType } from 'juniper'; const startsOrEndsWith = objectSchema() .patternProperties( '^abc' as PatternProperties<`abc${string}`>, true ) .patternProperties( 'xyz$' as PatternProperties<`${string}xyz`>, numberSchema() ); // Record<`abc${string}`, unknown> & Record<`${string}xyz`, number>; type Output = SchemaType<typeof startsWithAbc>;
- Example:
- StringSchema's
startsWith
,endsWith
, andcontains
are just wrappers around thepattern
property, but with special typescript handling. - TupleSchema is simply a wrapper around ArraySchema enforcing "strict" tuples (does not allow editing
items
). It is recommended but not necessary. Every TupleSchema is an ArraySchema. - JSON Schema interprets omitting
additionalProperties
as impliedadditionalProperties=true
. The emitted typescript will only include this extra index typing when explicitly set to true.- Example:
import { objectSchema, SchemaType } from 'juniper'; const empty = objectSchema(); // Actually called `EmptyObject`, see "Helper Types". type EmptyObject = SchemaType<typeof empty>; const indexed = empty.additionalProperties(true); // Record<string, unknown> type IndexedObject = SchemaType<typeof indexed>;
- Example:
Recipes
Some helpful schema recipes to get started and provide inspiration of how to proceed.
Typescript Enum
Typescript enums
are actually object dictionaries that sometimes have reverse mappings so cannot trivially get list of all values via something like Object.values
. The enum-to-array can resolve that.
import { enumToValues } from 'enum-to-array';
import { enumSchema } from 'juniper';
MyEnum {
FOO = 'BAR',
ABC = 123,
}
enumSchema({
enum: enumToValues(MyEnum)
}).toJSON();
// { enum: ['BAR', 123] }
Motivation (The JSON Schema Problem)
Json Schema is a powerful vocabulary for describing data formats that is both human and machine readable.
However, when it comes to generating and using these schemas, a few issues pop up:
- Strictness
- Validation of a Json Schema is quite loose, and does not enforce sensible schemas.
- For example:
appears to enforce an array with number elements. But due to the omission of{ "items": { "type": "number" } }
"type": "array"
any non-array value will also validate successfully!
Now we have added the{ "type": "array", "items": { "type": "number" }, "maxLength": 10 }
array
enforcement and even required no more than 10 elements. Except that is the wrong keyword!maxItems
enforces array length,maxLength
is for strings.
This schema object uses inconsistent case and as a result,{ "type": "object", "properties": { "foobar": { "type": "string" } }, "required": ["fooBar"] }
foobar
is never guaranteed to exist on the output! Furthermore it is unlikely data will validate at all because it is missing thefooBar
property (which could be anything).
- Libraries like Ajv have a
{ strict: true }
setting to help enforce this, but that only lets you know once you have already failed to write JSON Schema as expected. - Juniper solves this issue by strict typings on the schema generators. You must explicitly declare the type of the schema (e.g.
object
ornumber
) and only the properties related to that schema may be set.
- DRY
- Good code should be DRY (Don't repeat yourself). However, JSON Schema itself is just JSON, it doesn't mean anything in a Typescript runtime. Manually writing Typescript interfaces to match a JSON schema (and vice versa) quickly becomes a maintenance nightmare and opens up opportunities to mistype a schema.
- Juniper resolves this issue by generating the JSON Schema and the Typescript types at the same time. Typescript interfaces are emitted by JSON Schema attributes where possible, but does not limit schema generation due to that.
- See Comparisons for a list of alternatives that help DRY JSON Schema generation.
- Backwards Compatibility
- Json Schema comes in multiple "versions", often referred to as drafts. While these versions generally follow the patterns set by predecessors, some changes are breaking and are non-trivial to fix. Perhaps the most infamous is OpenApi's
nullable
keyword overtype: 'null'
. When generating Json Schemas for multiple environments, it can be tricky to maintain usage of the correct keywords. - By generating the JSON Schema dynamically, Juniper is able to adjust the outputted schema to fit the environment.
- Presently Juniper supports Draft 2020-12 (default) and OpenAPI 3.0.
- Json Schema comes in multiple "versions", often referred to as drafts. While these versions generally follow the patterns set by predecessors, some changes are breaking and are non-trivial to fix. Perhaps the most infamous is OpenApi's
Objectives
Juniper has the following goals for generating JSON Schema:
- JSON Schema compatibility
- Hopefully obvious, Juniper should expose the functionality of every JSON Schema keyword.
- Strict Typing
- As much as possible, any changes to a JSON Schema that can be reflected as a TS Type should alter the emitted interface.
- Generate Strict Schemas
- "Strictness" is defined by the Ajv Validator. Any outputted JSON Schema should be able to be passed to AJV with
{ strict: true }
with no errors.
- "Strictness" is defined by the Ajv Validator. Any outputted JSON Schema should be able to be passed to AJV with
- Enforce best practice
- JSON Schema itself has little restrictions of what a valid schema is, and even with "strict" schemas can create nonsensical schemas. Juniper is opinionated in only generating schemas that make logical sense.
- For example
BooleanSchema
does not allow setting thenot
keyword. Given there are only at most 3 possible values (true
,false
, and potentiallynull
) if it is desired to restrict the schema further, it is recommended to use theEnumSchema
instead.
- Multi-draft support
- As much as logically possible, outputted schemas should be compatible with multiple drafts.
- For example,
if
/then
/else
conditionals are converted toanyOf
pairs when rendered withopenApi30: true
.
- Catch errors at Build time
- Any validations enforcing schema structure should be applied at Typescript time. Code that successfully compiles to javascript should never throw an error during runtime due to validation issues.
Non-Goals
The following are non-goals for Juniper.
- Validation
- Juniper is not a validation library. It will also not catch "impossible" schemas such as:
import { stringSchema } from 'juniper'; const neverValid = stringSchema({ minLength: 10, maxLength: 5 });
- Juniper is not a validation library. It will also not catch "impossible" schemas such as:
- Predictable JSON Schema
- Juniper applies various "optimizations" to schemas in order to provide strictness, and also ensure logically correct JSON Schema. As a result, the internal structure of a schema should be treated as opaque, and only passed to a serializer (e.g.
JSON.stringify
) or a validator (e.g. anAjv
instance). Attempting to read/modify the resulting JSON manually may have unexpected consequences. - Example:
import { numberSchema } from 'juniper'; const schema = numberSchema({ type: 'number', multipleOf: 6, }) .nullable() .multipleOf(8) .allOf( numberSchema({ type: 'integer' }) ); /** * "Expected" schema: * { * "type": "number", * "multipleOf": 6, * "nullable": true, * "allOf": [{ * "type": "integer", * }], * } */ console.log(json.toJSON()) /** * Actual schema: * { * "type": "integer", * "multipleOf": 24, * "allOf": [{}], * } */
- Juniper applies various "optimizations" to schemas in order to provide strictness, and also ensure logically correct JSON Schema. As a result, the internal structure of a schema should be treated as opaque, and only passed to a serializer (e.g.
- Sensible Defaults
- JSON Schema does not apply any defaults to a schema that are not explicitly required. As such, properties like object's
required
,additionalProperties
orunevaluatedProperties
must be set manually. Perhaps the one exception isTupleSchema
which handles some values internally to ensure a strict tuple schema.
- JSON Schema does not apply any defaults to a schema that are not explicitly required. As such, properties like object's
- Performance
- While Juniper should not be a runtime bottleneck, it optimizes functionality over speed for schema generation. Schemas should (as much as possible) be generated only once, and at the start of the process.
Limitations
Juniper tries to emit Typescript types for related JSON Schemas. These types are generally best effort, and have some limitations.
- Unions
- Typescript does not have a way of differentiating between
oneOf
oranyOf
. Both will use the union pipe literal|
.
- Typescript does not have a way of differentiating between
- Negation
- The
not
keyword is not fully enforced in Typescript. The general TS equivalentExclude
does not enforce a specific type is not allowed. - For example:
// Legal, although seems like it should not be. const notAbc: Exclude<string, 'abc'> = 'abc'; const notAbc123: Exclude<{ abc: number }, { abc: 123 }> = { abc: 123 };
- The general exception is enforcement that a schema cannot be null.
- The
Comparisons
There are many other tools available for dealing with JSON Schema in a Typescript environment. While this list is not exhaustive, it provides insight to potential alternatives and feature disparity.
- Dynamic Schema Generation
- Javascript validation
- TS -> JSON Schema
- JSON Schema -> TS