Skip to content

A standard interface for TypeScript schema validation libraries

License

Notifications You must be signed in to change notification settings

standard-schema/standard-schema

Repository files navigation

This is a draft! Feel free to submit an issue to start discussions relating to this proposal!

🦆
Standard Schema

A proposal for a common standard interface for TypeScript and JavaScript schema validation libraries.


This is a proposal for a standard interface to be adopted across TypeScript validation libraries. The goal is to make it easier for open-source libraries to accept user-defined schemas as part of their API, in a library-agnostic way.Type safety is important, but it doesn't make sense for every API library and framework to implement their own runtime type validation system. This proposal establishes a common pattern for exchanging type validators between libraries.

The standard interface

interface StandardSchema<O, I> {
  '~output': O;
  '~input': I;
  // runtime only (does not need to be visible in the type signature)
  '~validate': (data: unknown) => O | ValidationError;
}

interface ValidationError {
  // runtime only (does not need to be visible in the type signature)
  '~validationerror': true;
  issues: Array<{
    message: string;
    path: (string | number | symbol)[];
  }>;
}

Accepting user-defined schemas

So, you're building a library and want to accept user-defined schemas. Great!

First, install standard-schema as a dev dependency. This package only contains types!

pnpm add --dev standard-schema

To accept a user-defined schema in your API, use a generic function parameter that extends StandardSchema.

import type { StandardSchema, OutputType, Decorate  } from 'standard-schema';

// example usage in libraries
function inferSchema<T extends StandardSchema>(schema: T) {
  return (schema as unknown) as Decorate<T>;
}

The Decorate utility takes the inferred type, extracts type information from it, and returns a fully typed object with a ~validate method. You can use this method to parse data.

import { ValidationError } from 'standard-schema';
import { CoolSchema } from 'some-cool-schema-library';

const someSchema = new CoolSchema<{ name: string }>();

const inferredSchema = inferSchema(someSchema);
const result = inferredSchema['~validate']({ name: 'Billie' });

function isValidationError(result: unknown): result is ValidationError {
  return (result as ValidationError)['~validationerror'] === true;
}

if (isValidationError(result)) {
  result.issues; // detailed error reporting
} else {
  result.name; // fully typed result
}

To extract the output and input types from a schema, use the OutputType and InputType utility types.

import type { OutputType, InputType } from 'standard-schema';

const someSchema = new CoolSchema<{ name: string }>();
const inferredSchema = inferSchema(someSchema);

type Output = OutputType<typeof someSchema>; // { name: string }
type Input = InputType<typeof someSchema>; // { name: string }

Implementing the standard: schema library authors

To make your library compatible with the Standard Schema spec, your library must be compatible in both the static and the runtime domain.

Static domain

This one is easy. Your schemas should conform to the following interface. This is all that's required in the static domain to be compatible with the Standard Schema spec.

interface StandardSchema {
  '~output': unknown;
}

The type signature of the ~output key should correspond to the inferred output type of the schema. This type can be extracted with the OutputType utility type.

export type OutputType<T extends StandardSchema> = T['~output'];

If your library implements any form of transform or coercion, it's possible the output type can diverge from the expected input type. If this is applicable to your library, your schemas should also include an ~input key.

interface StandardSchema {
  '~output': unknown;
  '~input': unknown;
}

This key isn't necessary. If it is omitted, the inferred input type of your schema will default to the output type.

export type InputType<T extends StandardSchema> = T extends {
  '~input': infer I;
}
  ? I
  : OutputType<T>; // defaults to output type

Runtime domain

At runtime, your schemas should implement the following interface.

interface StandardSchema<T> {
  // can be hidden from the public type signature (private/protected)
  '~validate': (data: unknown) => T | ValidationError;
}

interface ValidationError {
  '~validationerror': true;
  issues: Issue[];
}

interface Issue {
  message: string;
  path: (string | number | symbol)[];
}

The ~validate method does not need to be publicly visible on your schema's type signature. If implemented as an instance method, it can be marked private or protected on the base class to hide it from your end users.

Important: The ~validate method should not throw errors. Instead, it should either a) return the validated data on success, or b) return a ValidationError on failure.

Example

The following class implements a Standard Schema-compatible string validator.

import type {
  StandardSchema,
  OutputType,
  InputType,
  ValidationError,
  Decorate,
} from ".";

class StringSchema {
  "~output": string;

  // library-specific validation method
  parse(data: unknown): string {
    // do validation logic here
    if (typeof data === "string") return data;
    throw new Error("Invalid data");
  }

  // defining a ~validate method that conforms to the standard signature
  // can be private or protected
  private "~validate"(data: unknown) {
    try {
      return this.parse(data);
    } catch (err) {
      return {
        "~validationerror": true,
        issues: [
          {
            message: (err as Error)?.message,
            path: [],
          },
        ],
      };
    }
  }
}

FAQ

Do I need to include standard-schema as a dependency?

You can include standard-schema as a dev dependency and consume the library exclusively with import type. The library may export some runtime utility functions but they are only available for convenience.

Why tilde ~?

The goal of prefixing the key names with ~ is to both avoid conflicts with existing API surface and to de-prioritize these keys in auto-complete. The ~ character is one of the few ASCII characters that occurs after A-Za-z0-9 lexicographically, so VS Code puts these suggestions at the bottom of the list.

Screenshot 2024-04-10 at 5 48 30 PM

Why not use symbols for the keys?

In TypeScript, using a plain Symbol inline as a key always collapses to a simple symbol type. This would cause conflicts with other schema properties that use symbols.

const object = {
  [Symbol.for('~output')]: 'some data',
};
// { [k: symbol]: string }

By contrast, declaring the symbol externally makes it "nominally typed". This means the key is sorted in autocomplete under the variable name (e.g. testSymbol below). Thus, these symbol keys don't get sorted to the bottom of the autocomplete list, unlike ~-prefixed string keys.

Screenshot 2024-04-10 at 9 33 33 PM

Why does ~validate return a union?

The validation method must conform to the following interface:

type ValidationMethod<T> = (data: unknown) => T | ValidationError;

Note that the validation method should not throw to indicate an error. It's expensive to throw an error in JavaScript, so any standard method signature should support the ability to perform validation without throw for performance reasons.

Instead the method returns a union of T (the inferred output type) and ValidationError.

Why not use a discriminated union?

Many libraries provide a validation method that returns a discriminated union.

interface Schema<O> {
  '~validate': (
    data: any
  ) => { success: true; data: O } | { success: false; error: ValidationError };
}

This necessarily involves allocating a new object on every parse operation. For performance-sensitive applications, this isn't acceptable.

How does error reporting work?

On a failed validation, the ~validate method returns an object compatible with the ValidationError interface.

interface ValidationError {
  '~validationerror': true;
  issues: Issue[];
}

A ValidationError has a ~validationerror flag (to distinguish it from T) and an issues array. Each Issue is an object that conforms to the following interface.

interface Issue {
  message: string;
  path: (string | number | symbol)[];
}

This is intended to be as minimal as possible, while supporting common use cases like form validation.

Does ValidationError extend Error?

It could but it doesn't have to (and probably shouldn't). It's expensive to allocate Error instances in JavaScript, since it captures the stack trace at the time of creation. Many performance-sensitive libraries don't throw Errors for this reason.

About

A standard interface for TypeScript schema validation libraries

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published