Skip to content

Instantly share code, notes, and snippets.

@webstrand
Created May 21, 2019 18:33
Show Gist options
  • Select an option

  • Save webstrand/65e609015b386b5644b67a10102ecb55 to your computer and use it in GitHub Desktop.

Select an option

Save webstrand/65e609015b386b5644b67a10102ecb55 to your computer and use it in GitHub Desktop.
Runtime type validation for typescript
/**
* A TypeScript type compatible runtime type-verification system.
*
* Each instance of {@link Runtype} is a complete type-verification system that
* can prove that an _unknown_ type is actually _known_ type. Runtypes can be
* composed via the convenience constructors {@link Union},
* {@link Intersection}, {@link Tuple}, and {@link struct}. Additionally, when
* constructing a new {@link Runtype}, a parent constraint may be specified in
* {@link RuntypeConfig}.
*
* Intentionally similar to perl's `Type::Tiny` which is used by the backend to
* validate data structures send by the client.
*
* The {@link Runtype.assay} method provides diagnostic introspection into proof
* failures.
*
* @author webstrand
*/
/**
* A small dictionary of letters that should be proceeded by the indefinite
* article "an" instead of "a". This is far from perfect, but provides decent
* results for use in error messages.
* @ignore
*/
const AN: Record<string, true> = (
"8@AEIOXaeiouÀÁÄÅÆÉÒÓÖÜàáæèéíöüĀāīİŌōœΩαεωϵеℓ"
.split("")
.reduce(
(o, k) => {
o[k] = true;
return o;
},
{} as Record<string, true>
)
);
/**
* A recursive tree structure of messages from runtypes that failed to match.
* Keys 0..N represent messages from the immediate Runtype. The prior array
* represents messages from failed Runtypes that must match for the immediate
* runtype to also match.
* @typedef RuntypeReport
*/
export interface RuntypeReport {
0: string;
[i: number]: string | undefined;
prior?: RuntypeReport[]
}
/**
* Configuration object for {@link Runtype}.
*/
interface RuntypeConfig<T> {
/**
* Display name to set on the newly created {@link Runtype}.
*/
name?: string;
/**
* Either a string or a function that generates a string, producing a
* message that explains why the constraint failed to match `x`.
*/
explain?: string | ((x: unknown, propname: string) => string);
/**
* Parent {@link Runtype} constraint that will be checked beforehand.
*/
parent?: Runtype<T>;
}
export type RuntypeProof<T> = T extends Runtype<infer U> ? U : T;
export type RuntypeUnionResult<T extends [unknown, ...unknown[]]> = { [P in keyof T]: RuntypeProof<T[P]> }[any];
export type RuntypeIntersectionResult<T extends [unknown, ...unknown[]]> = Intersect<{ [P in keyof T]: T[P] extends Runtype<infer U> ? U : T[P] }[number]>;
export type RuntypeStructResult<T extends { [key: string]: unknown }> = { [P in keyof T]: RuntypeProof<T[P]> };
export type RuntypeTupleResult<T extends unknown[]> = { [P in keyof T]: RuntypeProof<T[P]> };
export type RuntypeRecordResult<T> = { [key: string]: RuntypeProof<T> };
export type RuntypeArrayResult<T> = RuntypeProof<T>[];
/**
* Generate a name for the constant type constraint.
*
* * `type === undefined` produces `"undefined"`
* * `type === null` produces `"null"`
* * `"toString" in type` produces `"Constant[${type}]"`
* * Otherwise, `"Constant[${JSON.stringify(type)}]"`
*
* @param type Non-runtype type
*/
function generateName(type: unknown): string {
const t = typeof type;
if(typeof type === "undefined") {
return "undefined";
}
else if(type === null) {
return "null";
}
else if(typeof type === "object" && "toString" in type!) {
return `Constant[${type}]`;
}
else {
return `Constant[${JSON.stringify(type)}]`;
}
}
/**
* Generate a name for a {@link RuntypeUnion} where each sub-type's name is
* joined by `" | "`. For example: `"Foo | Bar | Baz"`.
* @param types List of sub-types. Either Runtype or non-runtype.
*/
function generateNameForUnion(types: unknown[]): string {
return types.map((type) => type instanceof Runtype ? type.name : generateName(type)).join(" | ");
}
/**
* Generarte a name for a {@link RuntypeIntersection} where each sub-type's name
* is joined by `" | "`. For example: `"Foo & Bar & Baz"`.
* @param types List of sub-types. Either Runtype or non-runtype.
*/
function generateNameForIntersection(types: unknown[]): string {
return types.map((type) => type instanceof Runtype ? type.name : generateName(type)).join(" & ");
}
/** @ignore */
type ExplainGeneratorFunction = Exclude<RuntypeConfig<any>['explain'], undefined | string>;
/** @ignore */
function generateExplainGenerator(message: undefined | string | ExplainGeneratorFunction, name: string): ExplainGeneratorFunction {
const an = AN[name[0]] ? "an " : "a ";
if(typeof message === "function") {
return (x, propname) => propname + " is not " + an + name + ": " + message(x, propname);
}
else if(typeof message === "string") {
return (x, propname) => propname + " is not " + an + name + ": " + message;
}
else {
return (x, propname) => propname + " is not " + an + name;
}
}
/**
* Runtime type introspection and verification.
*
* @typeparam T Type that {@link prove} will prove or disprove.
* @typeparam U _Optional_ type that the {@link parent} proves or disproves.
*/
export class Runtype<T extends U, U = unknown> implements Runtype<T> {
/** Display name for debugging messages */
readonly name: string;
/** Parent type-constraint */
readonly parent?: Runtype<U>;
/** Prove that `x is T` */
readonly prove: (x: unknown) => x is T;
/** Generate a report concluding `x is not T` */
readonly assay: (x: unknown, propname?: string) => null|RuntypeReport;
/**
* Create a new Runtype that proves `x is T` when {@link prove} returns
* true.
*/
constructor(prove: (x: U) => boolean, name?: string);
/**
* Create a new Runtype that proves `x is T` when {@link prove} returns
* true.
*/
constructor(prove: (x: U) => boolean, config: RuntypeConfig<U>);
/**
* Create a new Runtype with the provided properties. For subclass
* constructors.
*/
constructor(prove: (x: U) => boolean, assay: ((x: unknown) => null | RuntypeReport), name: string, parent?: Runtype<U>);
constructor(prove: (x: U) => boolean, configOrAssay?: string|RuntypeConfig<U>|((x: unknown) => null | RuntypeReport), name?: string, parent?: Runtype<U>) {
if(typeof configOrAssay === "function") {
this.name = name!;
this.parent = parent;
this.prove = prove as (x: unknown) => x is T;
this.assay = configOrAssay;
}
else {
const config = typeof configOrAssay === "string" ? { name: configOrAssay} : configOrAssay || {};
name = this.name = config.name || prove.name;
parent = this.parent = config.parent;
const explain = generateExplainGenerator(config.explain, name);
if(parent) {
const parentProve = parent.prove;
this.prove = function (x): x is T {
return parentProve(x) && prove(x);
};
const parentAssay = parent.assay;
this.assay = function (x, propname = "<root>") {
const report = parentAssay(x, propname);
if(report !== null) return { 0: explain(x, propname), prior: [report] };
if(prove(x as U)) return null;
return { 0: explain(x, propname) };
};
}
else {
const _prove = this.prove = prove as (x: unknown) => x is T;
this.assay = (x, propname = "<root>") => _prove(x) ? null : ({ 0: explain(x, propname) });
}
}
}
/** Stringify to the display {@link name} */
toString() { return this.name }
/** `object` type constraint */
static object: Runtype<object> = new Runtype((x) => typeof x === "object" && x !== null, "object");
/** `Record<string, unknown>` type constraint */
static record: Runtype<Record<string, unknown>> = new Runtype((x) => typeof x === "object" && x !== null, "record");
/** `unknown[]` type constraint */
static array: Runtype<unknown[]> = new Runtype((x) => Array.isArray(x), "array");
/** `string` type constraint */
static string: Runtype<string> = new Runtype((x) => typeof x === "string", "string");
/** `number` type constraint */
static number: Runtype<number> = new Runtype((x) => typeof x === "number", "number");
/** `boolean` type constraint */
static boolean: Runtype<boolean> = new Runtype((x) => typeof x === "boolean", "boolean");
/** `undefined` type constraint */
static undefined: Runtype<undefined> = new Runtype((x) => typeof x === "undefined", "undefined");
/** `null` type constraint */
static null: Runtype<null> = new Runtype((x) => x === null, "null");
/** See {@link RuntypeUnion} */
static Union: typeof RuntypeUnion;
/** See {@link RuntypeIntersection} */
static Intersection: typeof RuntypeIntersection;
/** See {@link RuntypeMaybe} */
static Maybe: typeof RuntypeMaybe;
/** See {@link RuntypeStruct} */
static Struct: typeof RuntypeStruct;
/** See {@link RuntypeTuple} */
static Tuple: typeof RuntypeTuple;
/** See {@link RuntypeRecord} */
static Record: typeof RuntypeRecord;
/** See {@link RuntypeArray} */
static Array: typeof RuntypeArray;
}
/**
* A type constraint representing the union of a set of types.
* @typeparam T An array of at least one element. Non-Runtype elements are
* treated as constants.
*/
export class RuntypeUnion<T extends [unknown, ...unknown[]]> extends Runtype<RuntypeUnionResult<T>> {
/**
* Generate a new union type constraint that proves `x` satisfies one of the
* constraints in {@link types}.
* @param types A list of constraints. If an element is not a Runtype, it is
* treated as a constant.
* @param name Display name, defaults to {@link generateNameForUnion}.
*/
constructor(types: T, name: string = generateNameForUnion(types)) {
const prove: RuntypeUnion<T>["prove"] = function (x): x is RuntypeUnionResult<T> {
for(const type of types) {
if(type instanceof Runtype) {
if(type.prove(x)) return true;
}
else if(type === x) return true;
}
return false;
};
const an = AN[name[0]] ? "an " : "a ";
const assay: RuntypeUnion<T>["assay"] = function (x, propname = "<root>") {
const children: RuntypeReport[] = [];
for(const type of types) {
if(type instanceof Runtype) {
const report = type.assay(x, propname);
if(report === null) return null;
children.push(report);
}
else if(type === x) {
return null;
}
else {
children.push({ 0: propname + " !== " + generateName(type) });
}
}
if(children.length !== types.length) return null;
return {
0: propname + " is not " + an + name,
children,
};
};
super(prove, assay, name);
}
}
Runtype.Union = RuntypeUnion;
/**
* A type constraint representing the intersection of a set of types.
*
* Note: There's an issue with the intersection of {@link RuntypeStruct}s or
* {@link RuntypeTuples}. If excess properties are not allowed and the keys of
* all the tuples or structs in the intersection don't overlap, the intersection
* will always fail. This is because RuntypeIntersection checks only that _all_
* subtypes match, not that their structural equivalent matches as with
* TypeScript's `&`.
*
* This is a complicated problem to fix, so for now I'm leaving it alone.
*/
export class RuntypeIntersection<T extends [unknown, ...unknown[]]> extends Runtype<RuntypeIntersectionResult<T>> {
/**
* Generate a new intersection type constraint that proves `x` satisfies all of the
* constraints in {@link types}.
* @param types A list of type constraints. If an element is not a Runtype, it is
* treated as a constant.
* @param name Display name, defaults to {@link generateNameForIntersection}.
*/
constructor(types: T, name: string = generateNameForIntersection(types)) {
const prove: RuntypeIntersection<T>["prove"] = function (x): x is RuntypeIntersectionResult<T> {
for(const type of types) {
if(type instanceof Runtype) {
if(!type.prove(x)) return false;
}
else if(type !== x) return false;
}
return true;
};
const an = AN[name[0]] ? "an " : "a ";
const assay: RuntypeIntersection<T>["assay"] = function (x, propname = "<root>") {
const children: RuntypeReport[] = [];
for(const type of types) {
if(type instanceof Runtype) {
const report = type.assay(x, propname);
if(report !== null) children.push(report);
}
else if(type !== x) {
children.push({ 0: propname + " !== " + generateName(type) });
}
}
if(children.length === 0) return null;
return {
0: propname + " is not " + an + name,
children,
};
};
super(prove, assay, name);
}
}
Runtype.Intersection = RuntypeIntersection;
/**
* A type constraint representing an optional field in a {@link RuntypeStruct}
* type constraint.
*/
export class RuntypeMaybe<T> extends Runtype<RuntypeProof<T>> {
/**
* Generate an optional field wrapper for the given {@link type}.
* @param type A type constraint or constant to wrap.
* @param name Display name, only used if constant.
*/
constructor(type: T, name?: string) {
if(type instanceof Runtype) {
super(type.prove, type.assay, type.name, type.parent);
}
else {
super(
(x) => x === type,
(x, propname = "<root>") => x === type ? null : { 0: propname + " !== " + generateName(type) },
name || `Maybe[${generateName(type)}]`,
);
}
}
}
Runtype.Maybe = RuntypeMaybe;
/**
* A type constraint representing a primitive object with specific keys.
*/
export class RuntypeStruct<T extends Record<string, unknown>> extends Runtype<RuntypeStructResult<T>> {
/**
* Generate a primitive object type constraint matching the structure of
* {@link spec}.
* @param spec An object, where each key is a type constraint that must
* match with a corresponding key in `x`. If a key needs to be optional, use
* {@link RuntypeMaybe}.
* @param name Display name, defaults to `"struct"`.
* @param allowExtended When true, excess keys not listed in {@link spec}
* will not result in a disproof.
*/
constructor(spec: T, name: string = "Struct", allowExtended: boolean = true) {
const prove: RuntypeStruct<T>["prove"] = function (x): x is RuntypeStructResult<T> {
if(!Runtype.record.prove(x)) return false;
for(const key in spec) {
const type = spec[key];
if(type instanceof Runtype.Maybe) {
if(key in x && !type.prove(x[key])) return false;
}
if(type instanceof Runtype) {
if(!type.prove(x[key])) return false;
}
else {
if(x[key] !== type) return false;
}
}
if(!allowExtended) {
for(const key in x) {
if(!(key in spec)) return false;
}
}
return true;
};
const an = AN[name[0]] ? "an " : "a ";
const assay: RuntypeStruct<T>["assay"] = function (x, propname = "<root>") {
const parentReport = Runtype.record.assay(x, propname);
if(parentReport !== null) return { 0: propname + " is not " + an + name, prior: [parentReport] };
const children: RuntypeReport[] = [];
for(const key in spec) {
const type = spec[key];
if(type instanceof Runtype.Maybe) {
if(key in (x as Record<string, unknown>)) {
const report = type.assay((x as Record<string, unknown>)[key], propname + "." + key);
if(report !== null) children.push(report);
}
}
else if(type instanceof Runtype) {
const report = type.assay((x as Record<string, unknown>)[key], propname + "." + key)
if(report !== null) children.push(report);
}
else {
if((x as Record<string, unknown>)[key] !== type) {
children.push({ 0: propname + "." + key + " !== " + generateName(type) })
}
}
}
if(!allowExtended) {
for(const key in (x as Record<string, unknown>)) {
if(!(key in spec)) children.push({ 0: propname + "." + key + " is not in spec" });
}
}
return children.length === 0 ? null : {
0: propname + " is not " + an + name,
children,
};
};
super(prove, assay, name, Runtype.record);
}
}
Runtype.Struct = RuntypeStruct;
/**
* A type constraint representing a fixed-length tuple with specific values.
*/
export class RuntypeTuple<T extends unknown[]> extends Runtype<RuntypeTupleResult<T>> {
/**
* Generate a tuple type constraint matching the structure of {@link spec}.
* @param spec An array, where each value is a type constraint that must
* match with the corresponding value in `x`.
* @param name Display name, defaults to `"Tuple"`.
* @param allowExtended When true, values in `x` with an index greater than
* the last type constraint in {@link spec} will be ignored.
*/
constructor(spec: T, name: string = "Tuple", allowExtended: boolean = true) {
const prove: RuntypeTuple<T>["prove"] = function (x): x is RuntypeTupleResult<T> {
if(!Runtype.array.prove(x)) return false;
if(!allowExtended && x.length !== spec.length) return false;
for(let key = 0; key < spec.length; key += 1) {
const type = spec[key];
if(type instanceof Runtype.Maybe) {
if(key in x && !type.prove(x[key])) return false;
}
if(type instanceof Runtype) {
if(!type.prove(x[key])) return false;
}
else {
if(x[key] !== type) return false;
}
}
return true;
};
const an = AN[name[0]] ? "an " : "a ";
const assay: RuntypeTuple<T>["assay"] = function (x, propname = "<root>") {
const parentReport = Runtype.array.assay(x, propname);
if(parentReport !== null) return { 0: propname + " is not " + an + name, prior: [parentReport] };
if(!allowExtended && (x as []).length !== spec.length) {
return { 0: propname + " is not " + an + name + ": " + propname + ".length !== " + spec.length }
}
const children: RuntypeReport[] = [];
for(let key = 0; key < spec.length; key += 1) {
const type = spec[key];
if(type instanceof Runtype.Maybe) {
if(key in (x as [])) {
const report = type.assay((x as [])[key], propname + "[" + key + "]");
if(report !== null) children.push(report);
}
}
else if(type instanceof Runtype) {
const report = type.assay((x as [])[key], propname + "[" + key + "]");
if(report !== null) children.push(report);
}
else {
if((x as [])[key] !== type) {
children.push({ 0: propname + "[" + key + "] !== " + generateName(type) })
}
}
}
return children.length === 0 ? null : {
0: propname + " is not " + an + name,
children,
};
};
super(prove, assay, name, Runtype.array);
}
}
Runtype.Tuple = RuntypeTuple;
/**
* A type constraint representing a primitive object with values of a specific
* type.
*/
class RuntypeRecord<T> extends Runtype<RuntypeRecordResult<T>> {
/**
* Generate a new type constraint that requires all values on an object to
* match {@link type}.
* @param type Value type constraint.
* @param name Display name, defaults to `"Record[" + type.name + "]"`
*/
constructor(type: T, name: string = "Record[" + generateName(type) + "]") {
const an = AN[name[0]] ? "an " : "a ";
if(type instanceof Runtype) {
const prove: RuntypeRecord<T>["prove"] = function (x): x is RuntypeRecordResult<T> {
if(!Runtype.record.prove(x)) return false;
for(const key in x) {
if(!type.prove(x[key])) return false;
}
return true;
}
const assay: RuntypeRecord<T>["assay"] = function (x, propname = "<root>") {
const parentReport = Runtype.record.assay(x, propname);
if(parentReport !== null) return { 0: propname + " is not " + an + name, prior: [parentReport] };
const children: RuntypeReport[] = [];
for(const key in (x as Record<string, unknown>)) {
const report = type.assay((x as Record<string, unknown>)[key], propname + "." + key);
if(report !== null) children.push(report);
}
return children.length === 0 ? null : {
0: propname + " is not " + an + name,
children,
};
}
super(prove, assay, name, Runtype.record);
}
else {
const prove: RuntypeRecord<T>["prove"] = function (x): x is RuntypeRecordResult<T> {
if(!Runtype.record.prove(x)) return false;
for(const key in x) {
if(type !== x[key]) return false;
}
return true;
};
const assay: RuntypeRecord<T>["assay"] = function (x, propname = "<root>") {
const parentReport = Runtype.record.assay(x, propname);
if(parentReport !== null) return { 0: propname + " is not " + an + name, prior: [parentReport] };
const children: RuntypeReport[] = [];
const typename = generateName(type);
for(const key in (x as Record<string, unknown>)) {
if(type !== (x as Record<string, unknown>)[key]) {
children.push({ 0: propname + "." + key + " !== " + typename });
}
}
return children.length === 0 ? null : {
0: propname + " is not " + an + name,
children,
};
}
super(prove, assay, name, Runtype.record);
}
}
}
Runtype.Record = RuntypeRecord;
/**
* A type constraint representing an array with values of a specific type.
*/
export class RuntypeArray<T> extends Runtype<RuntypeArrayResult<T>> {
/**
* Generate a new array type constraint that requires all values to
* match {@link type}.
* @param type Value type constraint.
* @param name Display name, defaults to `"Record[" + type.name + "]"`.
*/
constructor(type: T, name: string = "Array[" + generateName(type) + "]") {
const an = AN[name[0]] ? "an " : "a ";
if(type instanceof Runtype) {
const prove: RuntypeArray<T>["prove"] = function (x): x is RuntypeArrayResult<T> {
if(!Runtype.array.prove(x)) return false;
for(let key = 0; key < x.length; key += 1) {
if(!type.prove(x[key])) return false;
}
return true;
}
const assay: RuntypeArray<T>["assay"] = function (x, propname = "<root>") {
const parentReport = Runtype.record.assay(x, propname);
if(parentReport !== null) return { 0: propname + " is not " + an + name, prior: [parentReport] };
const children: RuntypeReport[] = [];
for(let key = 0; key < (x as []).length; key += 1) {
const report = type.assay((x as [])[key], propname + "[" + key + "]");
if(report !== null) children.push(report);
}
return children.length === 0 ? null : {
0: propname + " is not " + an + name,
children,
};
}
super(prove, assay, name, Runtype.array);
}
else {
const prove: RuntypeArray<T>["prove"] = function (x): x is RuntypeArrayResult<T> {
if(!Runtype.array.prove(x)) return false;
for(let key = 0; key < x.length; key += 1) {
if(type !== x[key]) return false;
}
return true;
};
const assay: RuntypeArray<T>["assay"] = function (x, propname = "<root>") {
const parentReport = Runtype.record.assay(x, propname);
if(parentReport !== null) return { 0: propname + " is not " + an + name, prior: [parentReport] };
const children: RuntypeReport[] = [];
const typename = generateName(type);
for(let key = 0; key < (x as []).length; key += 1) {
if(type !== (x as [])[key]) {
children.push({ 0: propname + "[" + key + "] !== " + typename });
}
}
return children.length === 0 ? null : {
0: propname + " is not " + an + name,
children,
};
}
super(prove, assay, name, Runtype.array);
}
}
}
Runtype.Array = RuntypeArray;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment