import * as _ from "lodash" | |
/** This is how DataTypes are serialized. */ | |
export type DataType = | |
| { type: "string" } | |
| { type: "number" } | |
| { type: "boolean" } | |
| { type: "literal"; value: string | number | boolean } | |
| { type: "array"; inner: DataType } | |
// Tuple types are not quite working yet. | |
// | { type: "tuple"; values: Array<DataType> } | |
| { type: "map"; inner: DataType } | |
| { type: "object"; properties: { [key: string]: DataType } } | |
| { type: "any" } | |
| { type: "optional"; inner: DataType } | |
| { type: "or"; values: Array<DataType> } | |
type DataTypeMap = { [K in DataType["type"]]: Extract<DataType, { type: K }> } | |
type IsDataTypeMap = { | |
[K in keyof DataTypeMap]: ( | |
dataType: DataTypeMap[K], | |
value: unknown | |
) => boolean | |
} | |
function isPlainObject(value: unknown): value is {} { | |
return _.isPlainObject(value) | |
} | |
/** A map of DataType.type to validator functions. */ | |
const isDataTypeMap: IsDataTypeMap = { | |
string: (dataType, value) => _.isString(value), | |
number: (dataType, value) => _.isNumber(value), | |
boolean: (dataType, value) => _.isBoolean(value), | |
literal: (dataType, value) => _.isEqual(value, dataType.value), | |
array: (dataType, value) => | |
Array.isArray(value) && | |
value.every(innerValue => { | |
return isDataType(dataType.inner, innerValue) | |
}), | |
// Tuple types are not quite working yet. | |
// tuple: (dataType, value) => | |
// Array.isArray(value) && | |
// value.length === dataType.values.length && | |
// value.every((innerValue, index) => { | |
// return isDataType(dataType.values[index], innerValue) | |
// }), | |
map: (dataType, value) => | |
isPlainObject(value) && | |
Object.keys(value).every(_.isString) && | |
Object.values(value).every(innerValue => { | |
return isDataType(dataType.inner, innerValue) | |
}), | |
object: (dataType, value) => | |
isPlainObject(value) && | |
Object.keys(value).every(key => { | |
return isDataType(dataType.properties[key], value[key]) | |
}), | |
any: (dataType, value) => true, | |
or: (dataType, value) => | |
dataType.values.some(possibleDataType => { | |
return isDataType(possibleDataType, value) | |
}), | |
optional: (dataType, value) => | |
value === undefined || isDataType(dataType.inner, value), | |
} | |
/** Runtime validation for DataTypes. */ | |
export function isDataType<T extends DataType>(dataType: T, value: unknown) { | |
const is = isDataTypeMap[dataType.type] as ( | |
schema: DataType, | |
value: unknown | |
) => boolean | |
return is(dataType, value) | |
} | |
/** | |
* A runtime representation of a DataType that is serializable with runtime validation | |
* as well as TypeScript types available with `typeof DataType.value`. | |
*/ | |
export class RuntimeDataType<T> { | |
value: T | |
dataType: DataType | |
constructor(dataType: DataType) { | |
this.dataType = dataType | |
} | |
/** Convenient wrapper for `isDataType`. */ | |
is(value: unknown): value is T { | |
return isDataType(this.dataType, value) | |
} | |
toJSON() { | |
return this.dataType | |
} | |
} | |
// Runtime representations of each DataType. | |
export const string = new RuntimeDataType<string>({ type: "string" }) | |
export const number = new RuntimeDataType<number>({ type: "number" }) | |
export const boolean = new RuntimeDataType<boolean>({ type: "boolean" }) | |
export function literal<T extends string | number>(x: T) { | |
return new RuntimeDataType<T>({ type: "literal", value: x }) | |
} | |
export function optional<T>(inner: RuntimeDataType<T>) { | |
return new RuntimeDataType<T | undefined>({ | |
type: "optional", | |
inner: inner.dataType, | |
}) | |
} | |
export function array<T>(inner: RuntimeDataType<T>) { | |
return new RuntimeDataType<Array<T>>({ | |
type: "array", | |
inner: inner.dataType, | |
}) | |
} | |
// Tuple types are not quite working yet. I'm not sure how to make the generic | |
// a tuple of unwrapped values and then specify the argument as a tuple of | |
// wrapped values. | |
// export function tuple<T extends Array<RuntimeDataType<any>>>(...values: T) { | |
// return new RuntimeDataType<T>({ | |
// type: "tuple", | |
// values: values.map(value => value.dataType), | |
// }) | |
// } | |
export function map<T>(inner: RuntimeDataType<T>) { | |
return new RuntimeDataType<{ [key: string]: T }>({ | |
type: "map", | |
inner: inner.dataType, | |
}) | |
} | |
export function object<O extends { [key: string]: any }>( | |
schema: { [K in keyof O]: RuntimeDataType<O[K]> } | |
) { | |
const properties: { [key: string]: DataType } = {} | |
Object.keys(schema).forEach(key => { | |
properties[key] = schema[key].dataType | |
}) | |
return new RuntimeDataType<O>({ | |
type: "object", | |
properties: properties, | |
}) | |
} | |
export const any = new RuntimeDataType<any>({ type: "any" }) | |
export function or<T extends Array<RuntimeDataType<any>>>(...values: T) { | |
return new RuntimeDataType<T[number]["value"]>({ | |
type: "or", | |
values: values.map(value => value.dataType), | |
}) | |
} | |
// RuntimeDataTypes for DataTypes. Very Meta 🤯 | |
// We're going to mutate this array to avoid circular references. | |
const dataTypeDataTypeValues: Array<any> = [] | |
export const dataTypeDataType = new RuntimeDataType<DataType>({ | |
type: "or", | |
values: dataTypeDataTypeValues, | |
}) | |
const stringDataType = object({ type: literal("string") }) | |
const numberDataType = object({ type: literal("number") }) | |
const booleanDataType = object({ type: literal("boolean") }) | |
const literalDataType = object({ | |
type: literal("literal"), | |
value: or(string, number, boolean), | |
}) | |
const arrayDataType = object({ | |
type: literal("array"), | |
inner: dataTypeDataType, | |
}) | |
// Tuple types are not quite working yet. | |
// const tupleDataType = object({ | |
// type: literal("tuple"), | |
// values: array(dataTypeDataType), | |
// }) | |
const mapDataType = object({ | |
type: literal("map"), | |
inner: dataTypeDataType, | |
}) | |
const objectDataType = object({ | |
type: literal("object"), | |
properties: map(dataTypeDataType), | |
}) | |
const anyDataType = object({ type: literal("any") }) | |
const orDataType = object({ | |
type: literal("or"), | |
values: array(dataTypeDataType), | |
}) | |
const optionalDataType = object({ | |
type: literal("optional"), | |
inner: dataTypeDataType, | |
}) | |
// Specify all runtime type parameters | |
const runtimeDataTypeMap: { | |
[K in keyof DataTypeMap]: RuntimeDataType<DataTypeMap[K]> | |
} = { | |
string: stringDataType, | |
number: numberDataType, | |
boolean: booleanDataType, | |
literal: literalDataType, | |
array: arrayDataType, | |
// Tuple types are not quite working yet. | |
// tuple: tupleDataType, | |
map: mapDataType, | |
object: objectDataType, | |
any: anyDataType, | |
or: orDataType, | |
optional: optionalDataType, | |
} | |
// Type contrained! :) | |
dataTypeDataTypeValues.push(...Object.values(runtimeDataTypeMap)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment