npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

juniper

v1.2.2

Published

ESM JSON Schema builder for static Typescript inference.

Downloads

562

Readme

Juniper

ESM JSON Schema builder for static Typescript inference

npm package License

Contents

Introduction

Juniper is a JSON Schema generator that focuses on solving two problems:

  1. Writing strict and maintainable JSON Schemas.
  2. 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 imported. 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 of integer and number (default), based on the type field. It does not impact TS typings.
  • MergeSchema without "merging" anything can be used as a generic unknown. Using methods like allOf and anyOf can generate a mix of unrelated types like number | string.
  • TupleSchema is a convenience wrapper around ArraySchema ensuring "strict" tuples. The same functionality can be achieved via raw ArraySchema.
  • 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, as any is discouraged in favor of unknown (MergeSchema). If any 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 a CustomSchema. Otherwise it is designed for instances where common schemas are pulled into a reusable section, such as OpenApi's components section.
    • Example:
      import { 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
       *       },
       *     }
       *   }
       * }
       */
      Note the above example is not 100% compliant with OpenAPI spec. The 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:
      const nullableResourceSchema = mergeSchema().oneOf([
          resourceSchema,
          nullSchema
      ]);
      The resulting types are identical.
  • 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;
      }>();
  • 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.

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>;
  • StringSchema's startsWith, endsWith, and contains are just wrappers around the pattern 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 implied additionalProperties=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>;

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:
      • {
          "items": { "type": "number" }
        }
        appears to enforce an array with number elements. But due to the omission of "type": "array" any non-array value will also validate successfully!
      • {
          "type": "array",
          "items": { "type": "number" },
          "maxLength": 10
        }
        Now we have added the array enforcement and even required no more than 10 elements. Except that is the wrong keyword! maxItems enforces array length, maxLength is for strings.
      • {
          "type": "object",
          "properties": {
              "foobar": { "type": "string" }
          },
          "required": ["fooBar"]
        }
        This schema object uses inconsistent case and as a result, foobar is never guaranteed to exist on the output! Furthermore it is unlikely data will validate at all because it is missing the fooBar 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 or number) 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 over type: '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.

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.
  • 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 the not keyword. Given there are only at most 3 possible values (true, false, and potentially null) if it is desired to restrict the schema further, it is recommended to use the EnumSchema 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 to anyOf pairs when rendered with openApi30: 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 });
  • 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. an Ajv 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": [{}],
       * }
       */
  • 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 or unevaluatedProperties must be set manually. Perhaps the one exception is TupleSchema which handles some values internally to ensure a strict tuple schema.
  • 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 or anyOf. Both will use the union pipe literal |.
  • Negation
    • The not keyword is not fully enforced in Typescript. The general TS equivalent Exclude 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.

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.