This is a draft! Feel free to submit an issue to start discussions relating to this proposal!
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.
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)[];
}>;
}
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 }
To make your library compatible with the Standard Schema
spec, your library must be compatible in both the static and the runtime 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
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.
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: [],
},
],
};
}
}
}
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.
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.
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.
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
.
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.
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.
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.