UNPKG

dilswer

Version:

Blazingly fast data validation library with TypeScript integration.

1,054 lines (844 loc) 26.2 kB
# Dilswer ![GitHub](https://img.shields.io/github/license/ncpa0cpl/Dilswer?style=for-the-badge) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/ncpa0cpl/dilswer/test.yml?branch=master&style=for-the-badge) [![npm](https://img.shields.io/npm/v/dilswer?style=for-the-badge)](https://www.npmjs.com/package/dilswer) ![Libraries.io dependency status for latest release](https://img.shields.io/librariesio/release/npm/Dilswer?style=for-the-badge) ![GitHub last commit](https://img.shields.io/github/last-commit/ncpa0cpl/Dilswer?style=for-the-badge) Blazingly fast data validation library with TypeScript integration. ## Table Of Contents 1. [Quick Start](#quick-start) 1. [Create type definitions](#create-type-definitions) 2. [Create a TypeScript type from a Dilswer definition](#create-a-typescript-type-from-a-dilswer-definition) 3. [Create a validation function](#create-a-validation-function) 4. [Assertion function](#assertion-function) 5. [Standard Schema support](#standard-schema-support) 2. [Other features](#other-features) 1. [Json Schema generation](#json-schema-generation) 2. [TypeScript type definition generation](#typescript-type-definition-generation) 3. [Availabla Type's](#availabla-types) 1. [Number](#typenumber) 2. [Int](#typeint) 3. [String](#typestring) 4. [StringNumeral](#typestringnumeral) 5. [StringInt](#typestringint) 6. [StringMatching](#typestringmatchingregex) 7. [Boolean](#typeboolean) 8. [Symbol](#typesymbol) 9. [Null](#typenull) 10. [Undefined](#typeundefined) 11. [Function](#typefunction) 12. [Unknown](#typeunknown) 13. [OneOf](#typeoneoftypes) 14. [AllOf](#typealloftypes) 15. [Array](#typearraytypes) 16. [Record](#typerecordrecordstring-fielddescriptor) 17. [Dict](#typedicttypes) 18. [Set](#typesettypes) 19. [Literal](#typeliteralstring--number--boolean) 20. [InstanceOf](#typeinstanceofclass) 21. [Enum](#typeenumenum) 22. [EnumMember](#typeenummemberenum-member) 23. [Recursive](#typerecursivefunction) 24. [Custom](#typecustomfunction) 4. [Utility Functions](#utility-functions) 1. [And](#and) 2. [Omit](#omit) 3. [Pick](#pick) 4. [Partial](#partial) 5. [Required](#required) 6. [Exclude](#exclude) 5. [Metadata](#metadata) 1. [Assign Metadata](#assign-metadata) 2. [Read Metadata](#read-metadata) 3. [Metadata and JSON Schema's](#metadata-and-json-schemas) 6. [Parsing](#parsing) ## Quick Start #### Create type definitions ```ts // person-type.ts import { Type } from "dilswer"; // Record property types can be defined in a few different ways: const PersonDataType = Type.Record({ id: Type.String, name: Type.String, age: { type: Type.Number }, email: Type.Option(Type.String), friends: { type: Type.Array(Type.String), required: false }, }); // A TypeScript equivalent type of the above would be: // type Person = { // id: string; // name: string; // age: number; // email?: string; // friends?: string[]; // }; ``` **NOTE:** the `required` attribute in a RecordOf fields is set to `true` by default. #### Create a TypeScript type from a Dilswer definition It is possible to infer a TypeScript type from a Dilswer definition: ```ts import { Infer } from "dilswer"; import { PersonDataType } from "./person-type.ts"; type Person = Infer<typeof PersonDataType>; // Result: // type Person: { // id: string; // name: string; // age: number; // email?: string; // friends?: string[]; // } ``` #### Create a validation function ```ts import { validator } from "dilswer"; import { PersonDataType } from "./person-type.ts"; const isPerson = validator(PersonDataType); // Result: // const isPerson: (data: unknown) => data is { // friends?: string[]; // id: string; // name: string; // age: number; // } const person = await axios .get("https://my-api.io/get-person/1") .then((r) => r.data); if (isPerson(person)) { console.log("Name: ", person.name); // do something with person } else { console.error("`person` variable is not of expected type."); // handle the validation failure } ``` ##### validation details if you want to obtain more details about validation failures, validator can be passed a second argument that will change the bahavior of the validate function: ```ts import { validator } from "dilswer"; import { PersonDataType } from "./person-type.ts"; const isPerson = validator(PersonDataType, { details: true }); // Result: // const isPerson: (data: unknown) => { // success: false; // error: ValidationError; // } | { // success: true; // value: { // friends?: string[]; // id: string; // name: string; // age: number; // }; // } ``` #### Assertion function Assertion function can change the type of a variable by simply being called: ```ts import { Type, assertType } from "dilswer"; function foo(value: any) { try { assertType(Type.String, value); value.toUpperCase(); // value is now treated as string by TypeScript } catch (error) { console.error("Value is not a string"); } } ``` ### Standard Schema support Dilswer can be used with any library that supports the Standard Schema validation, like tRPC, OpenAuth and others. ```ts import { Type } from "dilswer"; import { initTRPC } from "@trpc/server"; const t = initTRPC.create(); const router = t.router({ greeting: t.procedure .input(Type.Record({ name: Type.String, })) .query(async ({ input }) => { return `Hello, ${input.name}!`; }), }) ``` For the best performance, type schemas passed to the other libraries should get compiled via the `.compile()` method. The compiled schemas do not provide as detailed error messages but are order of magnitude faster. ```ts const router = t.router({ greeting: t.procedure .input( Type.Record({ name: Type.String, }).compile() ) .query(async ({ input }) => { return `Hello, ${input.name}!`; }), }) ``` ## Other features ### Json Schema generation Translates given Type into a JSON Schema. ```ts const toJsonSchema: ( type: AnyType, options: ParseToJsonSchemaOptions = {}, include$schemaProperty = true ) => JSONSchema6 | undefined; ``` #### ParseToJsonSchemaOptions ```ts type ParseToJsonSchemaOptions = { /** * Defines how to handle DataTypes that do not have an * equivalent type in JSON Schema. (Set's, undefined, Symbols, * etc.) * * - `throw` (default): Throw an error if an incompatible type is * encountered. * - `omit`: Omits incompatible properties from the JSON Schema. * - `set-as-any`: Adds the type to the schema without a "type" * property but with a name equivalent to the given * DataType. */ incompatibleTypes?: "throw" | "omit" | "set-as-any"; /** * Determines if the schemas generated for Record's should have * additional properties set to `true` or `false`. */ additionalProperties?: boolean; /** * Custom Parser's are methods used to parse incompatible * DataTypes to JSON Schema's. * * By default a strategy defined in `incompatibleTypes` is * used, if a method is defined, that method will be used * instead. */ customParser?: { Set?: ( setItemsSchemas: JSONSchema6[], original: SetType<AnyType[]>, options: ParseToJsonSchemaOptions, ) => JSONSchema6 | undefined; Custom?: ( validateFunction: Custom["custom"], original: Custom, options: ParseToJsonSchemaOptions, ) => JSONSchema6 | undefined; Undefined?: ( dataType: BasicType, options: ParseToJsonSchemaOptions, ) => JSONSchema6 | undefined; Symbol?: ( dataType: BasicType, options: ParseToJsonSchemaOptions, ) => JSONSchema6 | undefined; Function?: ( dataType: BasicType, options: ParseToJsonSchemaOptions, ) => JSONSchema6 | undefined; }; }; ``` #### TypeScript type definition generation Translates given Type into a TypeScript type definition. This is not very useful at runtime, and is mostly intended for generating type definitions with JSDoc comments that can be bundled with libraries. ```ts const toTsType: ( dataType: AnyType, options?: Partial<TsParsingOptions>, ) => string; ``` #### TsParsingOptions ```ts type TsParsingOptions = { /** * Defines how to parse the type. * * - `compact` - the type will be parsed into a single type * definition * - `fully-expanded` - the type will be split into multiple type * definitions, and the main DataType type definition will * reference them. * - `named-expanded` - similar to `fully-expanded`, but only the * types that have titles assigned will be split into * separate type definitions. * * @default `compact` */ mode: TsParsingMode; /** * Defines how to export the generated types. * * - `main` - only the main DataType type will be exported * - `all` - all types generated will be exported * - `named` - only the types with titles will be exported * - `none` - nothing will be exported * * @default `main` */ exports: "main" | "named" | "all" | "none"; /** * Defines whether to generate the type as a declaration or * not. * * The difference is that declaration will generate each type * definition with a `declare` keyword preceding it. * * @default `false` */ declaration: boolean; /** * Defines how to handle duplicate names. * * - `error` - will throw an error if a duplicate name is * encountered * - `rename` - will rename the duplicate type * * @default `error` */ onDuplicateName: "error" | "rename"; /** * Some DataType can reference enums or classes, in which case * it's sometimes impossible to generate a valid TypeScript * type for them. By default just the name of that class/enum * will be used, and if that name is not available in the * global scope, TS will resolve it to `any`. This option * allows to define a custom import path for such types. * * @example * // foo.ts * export class Foo {} * * // data-type.ts * import { Foo } from "./foo"; * * export const dt = Type.RecordOf({ * foo: Type.InstanceOf(Foo), * }); * * // ts-type-generator.ts * import { dt } from "./data-type"; * import { Foo } from "./foo"; * * const tsType = toTsType(dt, { * getExternalTypeImport: (t) => { * if (t.instanceOf === Foo) { * return { * typeName: "Foo", * path: "./foo", * }; * } * }, * }); * // tsType: * //" * // import { Foo } from "./foo"; * // * // export type Record1 = { * // foo: InstanceType<typeof Foo>; * // } * //" */ getExternalTypeImport?: ( type: EnumType | EnumMemberType | InstanceType | CustomType | FunctionType, ) => ExternalTypeImport | undefined; }; type TsParsingMode = "compact" | "fully-expanded" | "named-expanded"; type ExternalTypeImport = { /** * Path to the file containing the external type. If the path * is not specified, the import statement will be omitted, so * for the generated declarations to be valid, you will have to * include that yourself or make the specified type available * in the global scope. */ path?: string; /** * Name of the type as it is to be used within the generated * declarations. * * If original name is not provided this is also the name of * the imported type. */ typeName: string; /** * Name of the type that will be used in the generated import * statement. */ originalName?: string; /** * Whether the imported name is a "value" or a "type". If it is * a "value" it will be referenced with a `typeof` keyword. */ valueImport?: boolean; }; ``` ### Availabla Type's #### Type.Number will match any number values and translate to the standard `number` type in TypeScript. #### Type.Int will match any integer values and translate to the standard `number` type in TypeScript. TypeScript does not have any way of distinguishing float and integers therefore both are assigned the same TypeScript type. #### Type.String will match any string values and translate to the standard `string` type in TypeScript. #### Type.StringNumeral will match any string containing only numeric values and translate to a `` `${number}` `` type in TypeScript. A value successfully validated with `StringNumeral` is safe to convert into a number and will never produce a `NaN` value. #### Type.StringInt will match any string containing only numbers and translate to a `` `${number}` `` type in TypeScript. Strings with floating point numbers are not matched by this type. A value successfully validated with `StringInt` is safe to convert into a number and will never produce a `NaN` value. #### Type.StringMatching(regex) will match any string matching the provided regular expression and translate to a the standard `string` type in TypeScript. #### Type.Boolean will match any `true` and `false` values and translate to the standard `boolean` type in TypeScript. #### Type.Symbol will match any symbolic values and translate to the `symbol` type in TypeScript. #### Type.Null will match only `null` value and translate to the standard `null` type in TypeScript. #### Type.Undefined will match only `undefined` value and translate to the standard `undefined` type in TypeScript. #### Type.Function will match any function and translate to the `Function` type in TypeScript. #### Type.Unknown will match any value and translate to the `unknown` type in TypeScript. #### Type.OneOf(...Type's) will match any value matching one of the DataType's provided in the arguments and translate to an TypeScript union type. Example ```ts const foo = Type.OneOf(Type.String, Type.Number); type T = Infer<typeof foo>; // type T = (string | number) ``` #### Type.AllOf(...Type's) will match values matching every DataType provided and translate to a TypeScript intersection of all those DataType's. Mostly useful to intersect multiple RecordOf's. Example ```ts const foo = Type.Record({ foo: string }); const bar = Type.Record({ bar: string }); const combined = Type.AllOf(foo, bar); type T = Infer<typeof combined>; // type T = { foo: string; bar: string; } ``` #### Type.Array(...Type's) will match any array which contains only values matching any of the DataType's provided in the arguments and translate to the `Array<...>` type in TypeScript. Example ```ts const foo = Type.Array(Type.String, Type.Number); type T = Infer<typeof foo>; // type T = (string | number)[] ``` #### Type.Record(Record<string, FieldDescriptor>) will match any object which structure matches the key-value pairs of object properties and FieldDescriptor's passed to the argument. Example ```ts const foo = Type.Record({ foo: Type.Boolean, bar: Type.String, baz: { type: Type.Number, required: false }, qux: Type.Option(Type.String), }); type T = Infer<typeof foo>; // type T = { foo: boolean, bar: string, baz?: number | undefined; qux?: string | undefined; } ``` #### Type.Dict(...Type's) will match any object which properties match against the provided DataTypes's, and translates to a Record type in TypeScript. Example ```ts const dictOfFunctions = Type.Dict(Type.Function); type T = Infer<typeof dictOfFunctions>; // type T = Record<string | number, Function> ``` #### Type.Set(...Type's) will match any Set object which contains only values matching any of the DataType's provided in the arguments and translate to the `Set<...>` type in TypeScript. Example ```ts const foo = Type.Set(Type.String, Type.Number); type T = Infer<typeof foo>; // type T = Set<string | number> ``` #### Type.Literal(string | number | boolean) will match any value that exactly matches the passed argument and translate to the literal type of that value in TypeScript. Example's ```ts const foo = Type.Literal("some-string-literal"); type T0 = GetDataType<typeof foo>; // type T0 = "some-string-literal" ``` ```ts const bar = Type.Literal(123); type T1 = GetDataType<typeof bar>; // type T1 = 123 ``` ```ts const baz = Type.Literal(true); type T2 = GetDataType<typeof baz>; // type T2 = true ``` #### Type.InstanceOf(class) will match any value that is an instance of the passed class and translate to the `InstanceType` type of that class in TypeScript. ```ts class FooBar { // ... } const foo = Type.InstanceOf(FooBar); type T = Infer<typeof foo>; // type T = InstanceType<typeof FooBar> ``` #### Type.Enum(enum) will match any value that belongs to an TypeScript enum and translate to that enum type. ```ts enum MyEnum { A = "A", B = "B", } const foo = Type.Enum(MyEnum); type T = Infer<typeof foo>; // type T = MyEnum const validate = validator(foo); validate(MyEnum.A); // => true validate(MyEnum.B); // => true ``` #### Type.EnumMember(enum member) will match any value that equals to the specified TypeScript enum member and translate to that enum member type. ```ts enum MyEnum { A = "VALUE_A", B = "VALUE_B", } const foo = Type.EnumMember(MyEnum.A); type T = Infer<typeof foo>; // type T = MyEnum.A const validate = validator(foo); validate("VALUE_A"); // => true validate(MyEnum.A); // => true validate(MyEnum.B); // => false ``` #### Type.Recursive(Function) Allows to define types that reference themselves. The function it accepts should always return a valid DataType, which the reference provided to that function will point to. Example ```ts const Node = Type.Recursive((self) => Type.Record({ tag: Type.String, children: Type.ArrayOf(self), }) ); // this is equivalent to the following type: type Node = { tag: string; children: Node[]; }; ``` Type definitions given for circular DataTypes via `GetDataType` and validation methods will not however include infinite recursion as they should. (this does not affect the runtime validation) Due to the TypeScript limitations, it's impossible for a inferred type to include a reference to itself, so to get a usable type we use some TypeScript magic to create a similar type that is 4-levels deep. For the above example the actual type you will get will look like this: ```ts type Node = { tag: string; children: Array<{ tag: string; children: Array<{ tag: string; children: Array<{ tag: string; children: Array<any>; }>; }>; }>; }; ``` If you absolutely need to get a type that has infinite recursion, you can use [toTsType](#totstype) utility to generate TypeScript code which will meet that need. #### Type.Custom(Function) will test the data with the provided function, provided function should return a boolean indicating if the tested value passed the validation, passed function should also have a type definition that looks like this: `(v: any) => v is T`, where T is any valid TS type. Example ```ts const NonEmptyString = Type.Custom( (v: any): v is string => typeof v === "string" && v.length > 0 ); type T = Infer<typeof NonEmptyString>; // type T = string const validate = validator(NonEmptyString); validate("foo"); // => true validate(""); // => false ``` ### Utility Functions #### And() `And()` utility function can combine two Record Type Definitions into one. If any of the properties between the two combined Type Defs have the same key-name, the definition of the second one takes priority. ```ts const typeDefOne = Type.Record({ foo: Type.Number, bar: Type.Number, }); const typeDefTwo = Type.Record({ bar: Type.Array(Type.String), baz: Type.Boolean, }); const typeDefSum = And(typeDefOne, typeDefTwo); // typeDefSum = { // foo: number; // bar: string[]; // baz: boolean; // } ``` #### Omit() `Omit()` utility function removes specified keys from a Record Type Definition. ```ts const typeDefOne = Type.Record({ foo: Type.Number, bar: Type.Number, baz: Type.Number, qux: Type.Number, }); const typeDefOmitted = Omit(typeDefOne, "bar", "qux"); // typeDefOmitted = { // foo: number; // baz: number; // } ``` #### Pick() `Pick()` utility function removes all not specified keys from a Record Type Definition. ```ts const typeDefOne = Type.Record({ foo: Type.Number, bar: Type.Number, baz: Type.Number, qux: Type.Number, }); const typeDefPick = Pick(typeDefOne, "bar", "qux"); // typeDefPick = { // bar: number; // qux: number; // } ``` #### Partial() `Partial()` utility type makes all the Record's Type Definition keys optional. ```ts const typeDefOne = Type.Record({ foo: Type.Number, bar: Type.String, baz: Type.Array(Type.Number), }); const typeDefPartial = Partial(typeDefOne); // typeDefPartial = { // foo?: number | undefined; // bar?: string | undefined; // baz?: number[] | undefined; // } ``` #### Required() `Required()` utility type makes all the Record's Type Definition keys to be required (vs optional). ```ts const typeDefOne = Type.Record({ foo: { type: Type.Number, required: false }, bar: { type: Type.String, required: false }, baz: { type: Type.Array(Type.Number), required: false }, }); const typeDefRequired = Required(typeDefOne); // typeDefRequired = { // foo: number; // bar: string; // baz: number[]; // } ``` #### Exclude() `Exclude()` utility function removes Type Definitions from an Type Def union. ```ts const typeDefOne = Type.OneOf( Type.String, Type.Number, Type.Boolean, ); const typeDefExcluded = Exclude(typeDefOne, DataType.Number); // typeDefExcluded = string | boolean; ``` ## Metadata Each Type can have metadata attached to it, this metadata can be used to provide additional information about the data type, for example, you can attach a description to a Type, or a title, or format. **Metadata is completely ignored by the validation process** ### Assign Metadata ```ts import { Type } from "dilswer"; const UserNameDT = Type.String.setTitle("User Name").setDescription( "The user's name.", ); const User = Type.Record({ name: UserNameDT, id: Type.String.setTitle("User ID").setFormat("uuid"), friends: Type.Array(Type.String).setDescription( "A list of the user's friends names.", ), }) .setTitle("User") .setDescription( "A user object. Contains the user's name, id and friends list.", ); ``` ### Read Metadata ```ts import { Type, getMetadata } from "dilswer"; const userNameMetadata = getMetadata(UserNameDT); // userNameMetadata = { // title: "User Name", // description: "The user's name.", // } const userMetadata = getMetadata(User); // userMetadata = { // title: "User", // description: "A user object. Contains the user's name, id and friends list.", // } ``` ### Metadata and JSON Schema's Metadata is also used when generating JSON Schema, if a DataType has a title, description or format, it will be included in the generated JSON Schema. ```ts import { Type, toJsonSchema } from "dilswer"; const UserDT = Type.Record({ name: Type.String.setTitle("User Name").setDescription( "The user's name.", ), id: Type.String.setTitle("User ID").setFormat("uuid"), friends: Type.Array(Type.String).setDescription( "A list of the user's friends names.", ), }) .setTitle("User") .setDescription( "A user object. Contains the user's name, id and friends list.", ); const jsonSchema = toJsonSchema(UserDT); // jsonSchema = { // title: "User", // description: "A user object. Contains the user's name, id and friends list.", // properties: { // name: { // type: "string", // title: "User Name", // description: "The user's name.", // }, // id: { // type: "string", // title: "User ID", // format: "uuid", // }, // friends: { // type: "array", // items: { // type: "string", // }, // }, // }, // required: ["name", "id", "friends"], // } ``` ### Parsing Dilswer data types can be easily parsed into any arbitrary data structure via `parseWith` function. This function takes a `visitor` object, which should contain a `visit` method, this method should generate a node of the new, desired data structure. This method is also used internally by `toJsonSchema` and `toTsType` functions. You can see the implementation of these functions in the source code [here](./src/json-schema-parser/to-json-schema.ts) and [here](./src/ts-type-generator/to-ts-type.ts). #### Example ```ts import { AnyType, Type, parseWith } from "dilswer"; // Define how the new structure should look like type TypeNode = { typeName: string; children?: TypeNode[] | Record<string, TypeNode>; }; // Create a visitor which will be used to translate Dilswer's data types into `Node`s const visitor = { visit( type: AnyType, children?: TypeNode[] | RecordOfVisitChild<TypeNode>[], ): TypeNode { switch (type.kind) { case "simple": return { typeName: type.simpleType }; case "record": return { typeName: "record", children: children ? Object.fromEntries( (children as RecordOfVisitChild<Node>[]).map( ({ propertyName, child }) => [propertyName, child], ), ) : undefined, }; default: return { typeName: type.kind, children: children as Node[] }; } }, }; // use the visitor on a Dilser data type const type = Type.Record({ foo: Type.String, bar: Type.Array(Type.Number), baz: Type.OneOf(Type.String, Type.Number), }); const nodeTree = parseWith(visitor, type); ``` ##### Example output ```json { "typeName": "record", "children": { "foo": { "typeName": "string" }, "bar": { "typeName": "array", "children": [ { "typeName": "number" } ] }, "baz": { "typeName": "union", "children": [ { "typeName": "string" }, { "typeName": "number" } ] } } } ```