diff --git a/benchmark/compression/module/typebox-custom.ts b/benchmark/compression/module/typebox-custom.ts new file mode 100644 index 0000000..8620c02 --- /dev/null +++ b/benchmark/compression/module/typebox-custom.ts @@ -0,0 +1,4 @@ +import { Custom } from 'src/custom/index' +import { Type } from '@sinclair/typebox' + +Custom.Set('custom', (value) => true) diff --git a/changelog.md b/changelog.md index 660e286..d453592 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,9 @@ +## [0.25.9](https://www.npmjs.com/package/@sinclair/typebox/v/0.25.9) + +Updates: + +- TypeBox now supports custom types. These types require the user to specify a custom `[Kind]` string on the type. Custom types can be registered via `Custom.Set('', (value) => { ... })`. + ## [0.25.0](https://www.npmjs.com/package/@sinclair/typebox/v/0.25.0) Updates: diff --git a/example/index.ts b/example/index.ts index 987d926..97c0303 100644 --- a/example/index.ts +++ b/example/index.ts @@ -3,6 +3,7 @@ import { TypeCompiler } from '@sinclair/typebox/compiler' import { Conditional } from '@sinclair/typebox/conditional' import { TypeGuard } from '@sinclair/typebox/guard' import { Format } from '@sinclair/typebox/format' +import { Custom } from '@sinclair/typebox/custom' import { Value, ValuePointer } from '@sinclair/typebox/value' import { Type, Static, TSchema } from '@sinclair/typebox' diff --git a/package.json b/package.json index 8d606c6..ab27031 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sinclair/typebox", - "version": "0.25.8", + "version": "0.25.9", "description": "JSONSchema Type Builder with Static Type Resolution for TypeScript", "keywords": [ "typescript", @@ -15,6 +15,7 @@ "exports": { "./compiler": "./compiler/index.js", "./conditional": "./conditional/index.js", + "./custom": "./custom/index.js", "./errors": "./errors/index.js", "./format": "./format/index.js", "./guard": "./guard/index.js", diff --git a/readme.md b/readme.md index 94ef873..f1a6a94 100644 --- a/readme.md +++ b/readme.md @@ -97,6 +97,7 @@ License MIT - [TypeCheck](#typecheck) - [Ajv](#typecheck-ajv) - [TypeCompiler](#typecheck-typecompiler) + - [Custom](#typecheck-custom) - [Formats](#typecheck-formats) - [Benchmark](#benchmark) - [Compile](#benchmark-compile) @@ -1117,6 +1118,30 @@ console.log(C.Code()) // return function check(va // } ``` + + +### Custom + +Use custom module to create a custom types. When creating a custom type you must specify a `Kind` symbol property on the types schema. The `Kind` symbol property should match the name used to register the type. Custom types are used by the Value and TypeCompiler modules only. + +The custom module is an optional import. + +```typescript +import { Custom } from '@sinclair/typebox/custom' +``` + +The following registers a BigInt custom type. + +```typescript +import { Type, Kind } from '@sinclair/typebox' + +Custom.Set('BigInt', value => typeof value === 'bigint') + +const T = Type.Unsafe({ [Kind]: 'BigInt' }) // const T = { [Kind]: 'BigInt' } + +const R = Value.Check(T, 65536n) // const R = true +``` + ### Formats @@ -1163,29 +1188,29 @@ This benchmark measures compilation performance for varying types. You can revie ┌──────────────────┬────────────┬──────────────┬──────────────┬──────────────┐ │ (index) │ Iterations │ Ajv │ TypeCompiler │ Performance │ ├──────────────────┼────────────┼──────────────┼──────────────┼──────────────┤ -│ Number │ 2000 │ ' 421 ms' │ ' 11 ms' │ ' 38.27 x' │ -│ String │ 2000 │ ' 332 ms' │ ' 11 ms' │ ' 30.18 x' │ -│ Boolean │ 2000 │ ' 291 ms' │ ' 12 ms' │ ' 24.25 x' │ -│ Null │ 2000 │ ' 266 ms' │ ' 9 ms' │ ' 29.56 x' │ -│ RegEx │ 2000 │ ' 486 ms' │ ' 17 ms' │ ' 28.59 x' │ -│ ObjectA │ 2000 │ ' 2791 ms' │ ' 50 ms' │ ' 55.82 x' │ -│ ObjectB │ 2000 │ ' 2896 ms' │ ' 37 ms' │ ' 78.27 x' │ -│ Tuple │ 2000 │ ' 1244 ms' │ ' 21 ms' │ ' 59.24 x' │ -│ Union │ 2000 │ ' 1258 ms' │ ' 25 ms' │ ' 50.32 x' │ -│ Vector4 │ 2000 │ ' 1513 ms' │ ' 21 ms' │ ' 72.05 x' │ -│ Matrix4 │ 2000 │ ' 850 ms' │ ' 11 ms' │ ' 77.27 x' │ -│ Literal_String │ 2000 │ ' 335 ms' │ ' 7 ms' │ ' 47.86 x' │ -│ Literal_Number │ 2000 │ ' 358 ms' │ ' 7 ms' │ ' 51.14 x' │ -│ Literal_Boolean │ 2000 │ ' 356 ms' │ ' 7 ms' │ ' 50.86 x' │ -│ Array_Number │ 2000 │ ' 689 ms' │ ' 9 ms' │ ' 76.56 x' │ -│ Array_String │ 2000 │ ' 728 ms' │ ' 12 ms' │ ' 60.67 x' │ -│ Array_Boolean │ 2000 │ ' 726 ms' │ ' 7 ms' │ ' 103.71 x' │ -│ Array_ObjectA │ 2000 │ ' 3631 ms' │ ' 35 ms' │ ' 103.74 x' │ -│ Array_ObjectB │ 2000 │ ' 3636 ms' │ ' 40 ms' │ ' 90.90 x' │ -│ Array_Tuple │ 2000 │ ' 2119 ms' │ ' 31 ms' │ ' 68.35 x' │ -│ Array_Union │ 2000 │ ' 1550 ms' │ ' 23 ms' │ ' 67.39 x' │ -│ Array_Vector4 │ 2000 │ ' 2200 ms' │ ' 18 ms' │ ' 122.22 x' │ -│ Array_Matrix4 │ 2000 │ ' 1494 ms' │ ' 14 ms' │ ' 106.71 x' │ +│ Number │ 2000 │ ' 427 ms' │ ' 13 ms' │ ' 32.85 x' │ +│ String │ 2000 │ ' 367 ms' │ ' 12 ms' │ ' 30.58 x' │ +│ Boolean │ 2000 │ ' 297 ms' │ ' 13 ms' │ ' 22.85 x' │ +│ Null │ 2000 │ ' 255 ms' │ ' 9 ms' │ ' 28.33 x' │ +│ RegEx │ 2000 │ ' 487 ms' │ ' 17 ms' │ ' 28.65 x' │ +│ ObjectA │ 2000 │ ' 2718 ms' │ ' 53 ms' │ ' 51.28 x' │ +│ ObjectB │ 2000 │ ' 2874 ms' │ ' 36 ms' │ ' 79.83 x' │ +│ Tuple │ 2000 │ ' 1221 ms' │ ' 22 ms' │ ' 55.50 x' │ +│ Union │ 2000 │ ' 1223 ms' │ ' 24 ms' │ ' 50.96 x' │ +│ Vector4 │ 2000 │ ' 1517 ms' │ ' 21 ms' │ ' 72.24 x' │ +│ Matrix4 │ 2000 │ ' 830 ms' │ ' 10 ms' │ ' 83.00 x' │ +│ Literal_String │ 2000 │ ' 334 ms' │ ' 8 ms' │ ' 41.75 x' │ +│ Literal_Number │ 2000 │ ' 357 ms' │ ' 6 ms' │ ' 59.50 x' │ +│ Literal_Boolean │ 2000 │ ' 353 ms' │ ' 7 ms' │ ' 50.43 x' │ +│ Array_Number │ 2000 │ ' 679 ms' │ ' 11 ms' │ ' 61.73 x' │ +│ Array_String │ 2000 │ ' 713 ms' │ ' 10 ms' │ ' 71.30 x' │ +│ Array_Boolean │ 2000 │ ' 710 ms' │ ' 7 ms' │ ' 101.43 x' │ +│ Array_ObjectA │ 2000 │ ' 3571 ms' │ ' 35 ms' │ ' 102.03 x' │ +│ Array_ObjectB │ 2000 │ ' 3567 ms' │ ' 41 ms' │ ' 87.00 x' │ +│ Array_Tuple │ 2000 │ ' 2083 ms' │ ' 19 ms' │ ' 109.63 x' │ +│ Array_Union │ 2000 │ ' 1559 ms' │ ' 23 ms' │ ' 67.78 x' │ +│ Array_Vector4 │ 2000 │ ' 2156 ms' │ ' 19 ms' │ ' 113.47 x' │ +│ Array_Matrix4 │ 2000 │ ' 1483 ms' │ ' 13 ms' │ ' 114.08 x' │ └──────────────────┴────────────┴──────────────┴──────────────┴──────────────┘ ``` @@ -1199,31 +1224,31 @@ This benchmark measures validation performance for varying types. You can review ┌──────────────────┬────────────┬──────────────┬──────────────┬──────────────┬──────────────┐ │ (index) │ Iterations │ ValueCheck │ Ajv │ TypeCompiler │ Performance │ ├──────────────────┼────────────┼──────────────┼──────────────┼──────────────┼──────────────┤ -│ Number │ 1000000 │ ' 28 ms' │ ' 6 ms' │ ' 6 ms' │ ' 1.00 x' │ -│ String │ 1000000 │ ' 25 ms' │ ' 20 ms' │ ' 11 ms' │ ' 1.82 x' │ -│ Boolean │ 1000000 │ ' 34 ms' │ ' 22 ms' │ ' 13 ms' │ ' 1.69 x' │ -│ Null │ 1000000 │ ' 37 ms' │ ' 28 ms' │ ' 10 ms' │ ' 2.80 x' │ -│ RegEx │ 1000000 │ ' 162 ms' │ ' 50 ms' │ ' 37 ms' │ ' 1.35 x' │ -│ ObjectA │ 1000000 │ ' 550 ms' │ ' 38 ms' │ ' 22 ms' │ ' 1.73 x' │ -│ ObjectB │ 1000000 │ ' 1033 ms' │ ' 58 ms' │ ' 38 ms' │ ' 1.53 x' │ -│ Tuple │ 1000000 │ ' 126 ms' │ ' 24 ms' │ ' 14 ms' │ ' 1.71 x' │ -│ Union │ 1000000 │ ' 326 ms' │ ' 25 ms' │ ' 16 ms' │ ' 1.56 x' │ -│ Recursive │ 1000000 │ ' 3089 ms' │ ' 436 ms' │ ' 101 ms' │ ' 4.32 x' │ -│ Vector4 │ 1000000 │ ' 155 ms' │ ' 24 ms' │ ' 12 ms' │ ' 2.00 x' │ -│ Matrix4 │ 1000000 │ ' 579 ms' │ ' 41 ms' │ ' 28 ms' │ ' 1.46 x' │ -│ Literal_String │ 1000000 │ ' 50 ms' │ ' 21 ms' │ ' 13 ms' │ ' 1.62 x' │ -│ Literal_Number │ 1000000 │ ' 46 ms' │ ' 22 ms' │ ' 11 ms' │ ' 2.00 x' │ +│ Number │ 1000000 │ ' 25 ms' │ ' 6 ms' │ ' 5 ms' │ ' 1.20 x' │ +│ String │ 1000000 │ ' 22 ms' │ ' 20 ms' │ ' 11 ms' │ ' 1.82 x' │ +│ Boolean │ 1000000 │ ' 21 ms' │ ' 20 ms' │ ' 10 ms' │ ' 2.00 x' │ +│ Null │ 1000000 │ ' 24 ms' │ ' 21 ms' │ ' 9 ms' │ ' 2.33 x' │ +│ RegEx │ 1000000 │ ' 158 ms' │ ' 46 ms' │ ' 37 ms' │ ' 1.24 x' │ +│ ObjectA │ 1000000 │ ' 566 ms' │ ' 36 ms' │ ' 24 ms' │ ' 1.50 x' │ +│ ObjectB │ 1000000 │ ' 1026 ms' │ ' 52 ms' │ ' 40 ms' │ ' 1.30 x' │ +│ Tuple │ 1000000 │ ' 121 ms' │ ' 23 ms' │ ' 14 ms' │ ' 1.64 x' │ +│ Union │ 1000000 │ ' 299 ms' │ ' 26 ms' │ ' 16 ms' │ ' 1.63 x' │ +│ Recursive │ 1000000 │ ' 3168 ms' │ ' 414 ms' │ ' 97 ms' │ ' 4.27 x' │ +│ Vector4 │ 1000000 │ ' 147 ms' │ ' 22 ms' │ ' 11 ms' │ ' 2.00 x' │ +│ Matrix4 │ 1000000 │ ' 573 ms' │ ' 40 ms' │ ' 29 ms' │ ' 1.38 x' │ +│ Literal_String │ 1000000 │ ' 48 ms' │ ' 19 ms' │ ' 10 ms' │ ' 1.90 x' │ +│ Literal_Number │ 1000000 │ ' 46 ms' │ ' 20 ms' │ ' 10 ms' │ ' 2.00 x' │ │ Literal_Boolean │ 1000000 │ ' 48 ms' │ ' 20 ms' │ ' 10 ms' │ ' 2.00 x' │ -│ Array_Number │ 1000000 │ ' 424 ms' │ ' 32 ms' │ ' 19 ms' │ ' 1.68 x' │ -│ Array_String │ 1000000 │ ' 483 ms' │ ' 34 ms' │ ' 28 ms' │ ' 1.21 x' │ -│ Array_Boolean │ 1000000 │ ' 432 ms' │ ' 35 ms' │ ' 26 ms' │ ' 1.35 x' │ -│ Array_ObjectA │ 1000000 │ ' 13895 ms' │ ' 2440 ms' │ ' 1495 ms' │ ' 1.63 x' │ -│ Array_ObjectB │ 1000000 │ ' 16261 ms' │ ' 2633 ms' │ ' 2011 ms' │ ' 1.31 x' │ -│ Array_Tuple │ 1000000 │ ' 1741 ms' │ ' 98 ms' │ ' 66 ms' │ ' 1.48 x' │ -│ Array_Union │ 1000000 │ ' 4825 ms' │ ' 232 ms' │ ' 87 ms' │ ' 2.67 x' │ -│ Array_Recursive │ 1000000 │ ' 54158 ms' │ ' 6966 ms' │ ' 1173 ms' │ ' 5.94 x' │ -│ Array_Vector4 │ 1000000 │ ' 2577 ms' │ ' 99 ms' │ ' 48 ms' │ ' 2.06 x' │ -│ Array_Matrix4 │ 1000000 │ ' 12648 ms' │ ' 397 ms' │ ' 249 ms' │ ' 1.59 x' │ +│ Array_Number │ 1000000 │ ' 454 ms' │ ' 32 ms' │ ' 18 ms' │ ' 1.78 x' │ +│ Array_String │ 1000000 │ ' 481 ms' │ ' 32 ms' │ ' 22 ms' │ ' 1.45 x' │ +│ Array_Boolean │ 1000000 │ ' 448 ms' │ ' 34 ms' │ ' 27 ms' │ ' 1.26 x' │ +│ Array_ObjectA │ 1000000 │ ' 13709 ms' │ ' 2360 ms' │ ' 1548 ms' │ ' 1.52 x' │ +│ Array_ObjectB │ 1000000 │ ' 16602 ms' │ ' 2585 ms' │ ' 1904 ms' │ ' 1.36 x' │ +│ Array_Tuple │ 1000000 │ ' 1831 ms' │ ' 96 ms' │ ' 63 ms' │ ' 1.52 x' │ +│ Array_Union │ 1000000 │ ' 4958 ms' │ ' 234 ms' │ ' 86 ms' │ ' 2.72 x' │ +│ Array_Recursive │ 1000000 │ ' 55876 ms' │ ' 7160 ms' │ ' 1138 ms' │ ' 6.29 x' │ +│ Array_Vector4 │ 1000000 │ ' 2381 ms' │ ' 99 ms' │ ' 47 ms' │ ' 2.11 x' │ +│ Array_Matrix4 │ 1000000 │ ' 11670 ms' │ ' 383 ms' │ ' 228 ms' │ ' 1.68 x' │ └──────────────────┴────────────┴──────────────┴──────────────┴──────────────┴──────────────┘ ``` @@ -1237,11 +1262,12 @@ The following table lists esbuild compiled and minified sizes for each TypeBox m ┌──────────────────────┬────────────┬────────────┬─────────────┐ │ (index) │ Compiled │ Minified │ Compression │ ├──────────────────────┼────────────┼────────────┼─────────────┤ -│ typebox/compiler │ ' 54 kb' │ ' 27 kb' │ '1.97 x' │ -│ typebox/conditional │ ' 44 kb' │ ' 17 kb' │ '2.45 x' │ +│ typebox/compiler │ ' 55 kb' │ ' 27 kb' │ '2.00 x' │ +│ typebox/conditional │ ' 45 kb' │ ' 18 kb' │ '2.44 x' │ +│ typebox/custom │ ' 0 kb' │ ' 0 kb' │ '2.61 x' │ │ typebox/format │ ' 0 kb' │ ' 0 kb' │ '2.66 x' │ -│ typebox/guard │ ' 22 kb' │ ' 11 kb' │ '2.05 x' │ -│ typebox/value │ ' 78 kb' │ ' 36 kb' │ '2.13 x' │ +│ typebox/guard │ ' 23 kb' │ ' 11 kb' │ '2.07 x' │ +│ typebox/value │ ' 80 kb' │ ' 37 kb' │ '2.15 x' │ │ typebox │ ' 12 kb' │ ' 6 kb' │ '1.89 x' │ └──────────────────────┴────────────┴────────────┴─────────────┘ ``` diff --git a/src/compiler/compiler.ts b/src/compiler/compiler.ts index 922e504..04d0d2b 100644 --- a/src/compiler/compiler.ts +++ b/src/compiler/compiler.ts @@ -29,6 +29,7 @@ THE SOFTWARE. import { ValueErrors, ValueError } from '../errors/index' import { TypeGuard } from '../guard/index' import { Format } from '../format/index' +import { Custom } from '../custom/index' import * as Types from '../typebox' // ------------------------------------------------------------------- @@ -289,6 +290,10 @@ export namespace TypeCompiler { yield `(${value} === null)` } + function* Kind(schema: Types.TSchema, value: string): IterableIterator { + yield `(custom('${schema[Types.Kind]}', ${value}))` + } + function* Visit(schema: T, value: string): IterableIterator { // Reference: Referenced schemas can originate from either additional schemas // or inline in the schema itself. Ideally the recursive path should align to @@ -350,7 +355,8 @@ export namespace TypeCompiler { case 'Void': return yield* Void(anySchema, value) default: - throw new TypeCompilerUnknownTypeError(schema) + if (!Custom.Has(anySchema[Types.Kind])) throw new TypeCompilerUnknownTypeError(schema) + return yield* Kind(anySchema, value) } } @@ -419,12 +425,19 @@ export namespace TypeCompiler { export function Compile(schema: T, references: Types.TSchema[] = []): TypeCheck { TypeGuard.Assert(schema, references) const code = Build(schema, references) - const func1 = globalThis.Function('format', code) - const func2 = func1((format: string, value: string) => { - if (!Format.Has(format)) return false - const func = Format.Get(format)! - return func(value) - }) - return new TypeCheck(schema, references, func2, code) + const compiledFunction = globalThis.Function('custom', 'format', code) + const checkFunction = compiledFunction( + (kind: string, value: unknown) => { + if (!Custom.Has(kind)) return false + const func = Custom.Get(kind)! + return func(value) + }, + (format: string, value: string) => { + if (!Format.Has(format)) return false + const func = Format.Get(format)! + return func(value) + }, + ) + return new TypeCheck(schema, references, checkFunction, code) } } diff --git a/src/conditional/structural.ts b/src/conditional/structural.ts index 62c04ac..2f04650 100644 --- a/src/conditional/structural.ts +++ b/src/conditional/structural.ts @@ -51,11 +51,12 @@ export namespace Structural { // Rules // ------------------------------------------------------------------------ - function AnyOrUnknownRule(right: Types.TSchema) { + function AnyUnknownOrCustomRule(right: Types.TSchema) { // https://github.com/microsoft/TypeScript/issues/40049 - if (right[Types.Kind] === 'Union' && right.anyOf.some((schema: Types.TSchema) => schema[Types.Kind] === 'Any' || schema[Types.Kind] === 'Unknown')) return true - if (right[Types.Kind] === 'Unknown') return true - if (right[Types.Kind] === 'Any') return true + if (TypeGuard.TUnion(right) && right.anyOf.some((schema: Types.TSchema) => schema[Types.Kind] === 'Any' || schema[Types.Kind] === 'Unknown')) return true + if (TypeGuard.TUnknown(right)) return true + if (TypeGuard.TAny(right)) return true + if (TypeGuard.TCustom(right)) throw Error(`Structural: Cannot structurally compare custom type '${right[Types.Kind]}'`) return false } @@ -138,11 +139,11 @@ export namespace Structural { // ------------------------------------------------------------------------ function Any(left: Types.TAny, right: Types.TSchema): StructuralResult { - return AnyOrUnknownRule(right) ? StructuralResult.True : StructuralResult.Union + return AnyUnknownOrCustomRule(right) ? StructuralResult.True : StructuralResult.Union } function Array(left: Types.TArray, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right)) { if (right.properties['length'] !== undefined && right.properties['length'][Types.Kind] === 'Number') return StructuralResult.True @@ -163,7 +164,7 @@ export namespace Structural { } function Boolean(left: Types.TBoolean, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right) && ObjectRightRule(left, right)) { return StructuralResult.True @@ -177,7 +178,7 @@ export namespace Structural { } function Constructor(left: Types.TConstructor, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right) && globalThis.Object.keys(right.properties).length === 0) { return StructuralResult.True @@ -198,7 +199,7 @@ export namespace Structural { } function Date(left: Types.TDate, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right) && ObjectRightRule(left, right)) { return StructuralResult.True @@ -214,7 +215,7 @@ export namespace Structural { } function Function(left: Types.TFunction, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right)) { if (right.properties['length'] !== undefined && right.properties['length'][Types.Kind] === 'Number') return StructuralResult.True @@ -236,7 +237,7 @@ export namespace Structural { } function Integer(left: Types.TInteger, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right) && ObjectRightRule(left, right)) { return StructuralResult.True @@ -250,7 +251,7 @@ export namespace Structural { } function Literal(left: Types.TLiteral, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right) && ObjectRightRule(left, right)) { return StructuralResult.True @@ -278,7 +279,7 @@ export namespace Structural { } function Number(left: Types.TNumber, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right) && ObjectRightRule(left, right)) { return StructuralResult.True @@ -294,7 +295,7 @@ export namespace Structural { } function Null(left: Types.TNull, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TNull(right)) { return StructuralResult.True @@ -319,7 +320,7 @@ export namespace Structural { } function Object(left: Types.TObject, right: Types.TAnySchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right)) { return Properties(PropertyMap(left), PropertyMap(right)) @@ -335,7 +336,7 @@ export namespace Structural { } function Promise(left: Types.TPromise, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right)) { if (ObjectRightRule(left, right) || globalThis.Object.keys(right.properties).length === 0) { @@ -352,7 +353,7 @@ export namespace Structural { } function Record(left: Types.TRecord, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right)) { if (RecordPattern(left) === '^.*$' && right[Types.Hint] === 'Record') { @@ -394,7 +395,7 @@ export namespace Structural { } function String(left: Types.TString, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right) && ObjectRightRule(left, right)) { return StructuralResult.True @@ -410,7 +411,7 @@ export namespace Structural { } function Tuple(left: Types.TTuple, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right)) { const result = ObjectRightRule(left, right) || globalThis.Object.keys(right.properties).length === 0 @@ -442,7 +443,7 @@ export namespace Structural { } function Uint8Array(left: Types.TUint8Array, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TObject(right) && ObjectRightRule(left, right)) { return StructuralResult.True @@ -458,7 +459,7 @@ export namespace Structural { } function Undefined(left: Types.TUndefined, right: Types.TSchema): StructuralResult { - if (AnyOrUnknownRule(right)) { + if (AnyUnknownOrCustomRule(right)) { return StructuralResult.True } else if (TypeGuard.TUndefined(right)) { return StructuralResult.True @@ -562,6 +563,8 @@ export namespace Structural { return Unknown(left, resolvedRight) } else if (TypeGuard.TVoid(left)) { return Void(left, resolvedRight) + } else if (TypeGuard.TCustom(left)) { + throw Error(`Structural: Cannot structurally compare custom type '${left[Types.Kind]}'`) } else { throw Error(`Structural: Unknown left operand '${left[Types.Kind]}'`) } diff --git a/src/custom/custom.ts b/src/custom/custom.ts new file mode 100644 index 0000000..0f91285 --- /dev/null +++ b/src/custom/custom.ts @@ -0,0 +1,54 @@ +/*-------------------------------------------------------------------------- + +@sinclair/typebox/custom + +The MIT License (MIT) + +Copyright (c) 2022 Haydn Paterson (sinclair) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +---------------------------------------------------------------------------*/ + +export type CustomValidationFunction = (value: unknown) => boolean + +/** Provides functions to create custom types */ +export namespace Custom { + const customs = new Map() + + /** Clears all custom types */ + export function Clear() { + return customs.clear() + } + + /** Returns true if this kind exists */ + export function Has(kind: string) { + return customs.has(kind) + } + + /** Sets a validation function for a custom kind */ + export function Set(kind: string, func: CustomValidationFunction) { + customs.set(kind, func) + } + + /** Gets a custom validation function */ + export function Get(kind: string) { + return customs.get(kind) + } +} diff --git a/src/custom/index.ts b/src/custom/index.ts new file mode 100644 index 0000000..5c5b0bf --- /dev/null +++ b/src/custom/index.ts @@ -0,0 +1,29 @@ +/*-------------------------------------------------------------------------- + +@sinclair/typebox/custom + +The MIT License (MIT) + +Copyright (c) 2022 Haydn Paterson (sinclair) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +---------------------------------------------------------------------------*/ + +export * from './custom' diff --git a/src/errors/errors.ts b/src/errors/errors.ts index c7f43e7..f2cd2d0 100644 --- a/src/errors/errors.ts +++ b/src/errors/errors.ts @@ -28,6 +28,7 @@ THE SOFTWARE. import * as Types from '../typebox' import { Format } from '../format/index' +import { Custom } from '../custom/index' // ------------------------------------------------------------------- // ValueErrorType @@ -82,6 +83,7 @@ export enum ValueErrorType { Uint8ArrayMinByteLength, Uint8ArrayMaxByteLength, Void, + Custom, } // ------------------------------------------------------------------- @@ -378,7 +380,6 @@ export namespace ValueErrors { if (!(value instanceof globalThis.Uint8Array)) { return yield { type: ValueErrorType.Uint8Array, schema, path, value, message: `Expected Uint8Array` } } - if (IsNumber(schema.maxByteLength) && !(value.length <= schema.maxByteLength)) { yield { type: ValueErrorType.Uint8ArrayMaxByteLength, schema, path, value, message: `Expected Uint8Array to have a byte length less or equal to ${schema.maxByteLength}` } } @@ -395,6 +396,13 @@ export namespace ValueErrors { } } + function* CustomType(schema: Types.TSchema, references: Types.TSchema[], path: string, value: any): IterableIterator { + const func = Custom.Get(schema[Types.Kind])! + if (!func(value)) { + return yield { type: ValueErrorType.Custom, schema, path, value, message: `Expected kind ${schema[Types.Kind]}` } + } + } + function* Visit(schema: T, references: Types.TSchema[], path: string, value: any): IterableIterator { const anyReferences = schema.$id === undefined ? references : [schema, ...references] const anySchema = schema as any @@ -446,7 +454,8 @@ export namespace ValueErrors { case 'Void': return yield* Void(anySchema, anyReferences, path, value) default: - throw new ValueErrorsUnknownTypeError(schema) + if (!Custom.Has(anySchema[Types.Kind])) throw new ValueErrorsUnknownTypeError(schema) + return yield* CustomType(anySchema, anyReferences, path, value) } } diff --git a/src/guard/guard.ts b/src/guard/guard.ts index ca61df3..429e908 100644 --- a/src/guard/guard.ts +++ b/src/guard/guard.ts @@ -26,11 +26,12 @@ THE SOFTWARE. ---------------------------------------------------------------------------*/ +import { Custom } from '../custom/index' import * as Types from '../typebox' -export class TypeGuardInvalidTypeError extends Error { +export class TypeGuardUnknownTypeError extends Error { constructor(public readonly schema: unknown) { - super('TypeGuard: Invalid type') + super('TypeGuard: Unknown type') } } @@ -441,6 +442,11 @@ export namespace TypeGuard { ) } + /** Returns true if the given schema is a registered custom type */ + export function TCustom(schema: unknown): schema is Types.TSchema { + return IsObject(schema) && IsString(schema[Types.Kind]) && Custom.Has(schema[Types.Kind]) + } + /** Returns true if the given schema is TSchema */ export function TSchema(schema: unknown): schema is Types.TSchema { return ( @@ -466,15 +472,16 @@ export namespace TypeGuard { TUnion(schema) || TUint8Array(schema) || TUnknown(schema) || - TVoid(schema) + TVoid(schema) || + TCustom(schema) ) } /** Asserts if this schema and associated references are valid. */ export function Assert(schema: T, references: Types.TSchema[] = []) { - if (!TSchema(schema)) throw new TypeGuardInvalidTypeError(schema) + if (!TSchema(schema)) throw new TypeGuardUnknownTypeError(schema) for (const schema of references) { - if (!TSchema(schema)) throw new TypeGuardInvalidTypeError(schema) + if (!TSchema(schema)) throw new TypeGuardUnknownTypeError(schema) } } } diff --git a/src/tsconfig.json b/src/tsconfig.json index 3bca8a3..c8ce66e 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../tsconfig.json", - "files": ["compiler/index.ts", "conditional/index.ts", "format/index.ts", "guard/index.ts", "value/index.ts", "typebox.ts"] + "files": ["compiler/index.ts", "conditional/index.ts", "custom/index.ts", "format/index.ts", "guard/index.ts", "value/index.ts", "typebox.ts"] } diff --git a/src/value/cast.ts b/src/value/cast.ts index 19c7b91..094cbfd 100644 --- a/src/value/cast.ts +++ b/src/value/cast.ts @@ -30,6 +30,7 @@ import * as Types from '../typebox' import { ValueCreate } from './create' import { ValueCheck } from './check' import { ValueClone } from './clone' +import { Custom } from '../custom/index' // ---------------------------------------------------------------------------------------------- // Errors @@ -322,6 +323,10 @@ export namespace ValueCast { return ValueCheck.Check(schema, references, value) ? ValueClone.Clone(value) : ValueCreate.Create(schema, references) } + function Kind(schema: Types.TSchema, references: Types.TSchema[], value: any): any { + return ValueCheck.Check(schema, references, value) ? ValueClone.Clone(value) : ValueCreate.Create(schema, references) + } + export function Visit(schema: Types.TSchema, references: Types.TSchema[], value: any): any { const anyReferences = schema.$id === undefined ? references : [schema, ...references] const anySchema = schema as any @@ -375,7 +380,8 @@ export namespace ValueCast { case 'Void': return Void(anySchema, anyReferences, value) default: - throw new ValueCastUnknownTypeError(anySchema) + if (!Custom.Has(anySchema[Types.Kind])) throw new ValueCastUnknownTypeError(anySchema) + return Kind(anySchema, anyReferences, value) } } diff --git a/src/value/check.ts b/src/value/check.ts index e17d90c..7c7c8be 100644 --- a/src/value/check.ts +++ b/src/value/check.ts @@ -27,7 +27,8 @@ THE SOFTWARE. ---------------------------------------------------------------------------*/ import * as Types from '../typebox' -import { Format } from '../format' +import { Format } from '../format/index' +import { Custom } from '../custom/index' export class ValueCheckUnknownTypeError extends Error { constructor(public readonly schema: Types.TSchema) { @@ -297,6 +298,12 @@ export namespace ValueCheck { return value === null } + function Kind(schema: Types.TSchema, references: Types.TSchema[], value: unknown): boolean { + if (!Custom.Has(schema[Types.Kind])) return false + const func = Custom.Get(schema[Types.Kind])! + return func(value) + } + function Visit(schema: T, references: Types.TSchema[], value: any): boolean { const anyReferences = schema.$id === undefined ? references : [schema, ...references] const anySchema = schema as any @@ -348,7 +355,8 @@ export namespace ValueCheck { case 'Void': return Void(anySchema, anyReferences, value) default: - throw new ValueCheckUnknownTypeError(anySchema) + if (!Custom.Has(anySchema[Types.Kind])) throw new ValueCheckUnknownTypeError(anySchema) + return Kind(anySchema, anyReferences, value) } } diff --git a/src/value/create.ts b/src/value/create.ts index bb742c4..ad67b77 100644 --- a/src/value/create.ts +++ b/src/value/create.ts @@ -27,6 +27,7 @@ THE SOFTWARE. ---------------------------------------------------------------------------*/ import * as Types from '../typebox' +import { Custom } from '../custom/index' export class ValueCreateUnknownTypeError extends Error { constructor(public readonly schema: Types.TSchema) { @@ -277,6 +278,14 @@ export namespace ValueCreate { return null } + function Kind(schema: Types.TSchema, references: Types.TSchema[]): any { + if (schema.default !== undefined) { + return schema.default + } else { + throw new Error('ValueCreate.Kind: Custom types must specify a default value') + } + } + /** Creates a value from the given schema. If the schema specifies a default value, then that value is returned. */ export function Visit(schema: T, references: Types.TSchema[]): Types.Static { const anyReferences = schema.$id === undefined ? references : [schema, ...references] @@ -332,7 +341,8 @@ export namespace ValueCreate { case 'Void': return Void(anySchema, anyReferences) default: - throw new ValueCreateUnknownTypeError(anySchema) + if (!Custom.Has(anySchema[Types.Kind])) throw new ValueCreateUnknownTypeError(anySchema) + return Kind(anySchema, anyReferences) } } diff --git a/test/runtime/compiler/custom.ts b/test/runtime/compiler/custom.ts new file mode 100644 index 0000000..14ef55e --- /dev/null +++ b/test/runtime/compiler/custom.ts @@ -0,0 +1,28 @@ +import { Custom } from '@sinclair/typebox/custom' +import { Type, Kind } from '@sinclair/typebox' +import { ok, fail } from './validate' + +describe('type/compiler/Custom', () => { + Custom.Set('BigInt', (value) => typeof value === 'bigint') + + it('Should validate bigint', () => { + const T = Type.Unsafe({ [Kind]: 'BigInt' }) + ok(T, 1n) + }) + it('Should not validate bigint', () => { + const T = Type.Unsafe({ [Kind]: 'BigInt' }) + fail(T, 1) + }) + it('Should validate bigint nested', () => { + const T = Type.Object({ + x: Type.Unsafe({ [Kind]: 'BigInt' }), + }) + ok(T, { x: 1n }) + }) + it('Should not validate bigint nested', () => { + const T = Type.Object({ + x: Type.Unsafe({ [Kind]: 'BigInt' }), + }) + fail(T, { x: 1 }) + }) +}) diff --git a/test/runtime/compiler/index.ts b/test/runtime/compiler/index.ts index 01159a6..d063249 100644 --- a/test/runtime/compiler/index.ts +++ b/test/runtime/compiler/index.ts @@ -1,6 +1,7 @@ import './any' import './array' import './boolean' +import './custom' import './date' import './enum' import './integer' diff --git a/test/runtime/errors/index.ts b/test/runtime/errors/index.ts new file mode 100644 index 0000000..3443ef9 --- /dev/null +++ b/test/runtime/errors/index.ts @@ -0,0 +1 @@ +// todo: implement once errors are standardized diff --git a/test/runtime/index.ts b/test/runtime/index.ts index 77bb736..ea14962 100644 --- a/test/runtime/index.ts +++ b/test/runtime/index.ts @@ -1,5 +1,6 @@ import './compiler/index' import './conditional/index' +import './errors/index' import './format/index' import './guard/index' import './schema/index' diff --git a/test/runtime/value/cast/custom.ts b/test/runtime/value/cast/custom.ts new file mode 100644 index 0000000..ceb9311 --- /dev/null +++ b/test/runtime/value/cast/custom.ts @@ -0,0 +1,64 @@ +import { Value } from '@sinclair/typebox/value' +import { Custom } from '@sinclair/typebox/custom' +import { Type, Kind } from '@sinclair/typebox' +import { Assert } from '../../assert/index' + +describe('value/cast/Custom', () => { + Custom.Set('CustomCast', (value) => value === 'hello' || value === 'world') + const T = Type.Unsafe({ [Kind]: 'CustomCast', default: 'hello' }) + const E = 'hello' + + it('Should upcast from string', () => { + const value = 'hello' + const result = Value.Cast(T, value) + Assert.deepEqual(result, E) + }) + + it('Should upcast from number', () => { + const value = 1 + const result = Value.Cast(T, value) + Assert.deepEqual(result, E) + }) + + it('Should upcast from boolean', () => { + const value = false + const result = Value.Cast(T, value) + Assert.deepEqual(result, E) + }) + + it('Should upcast from object', () => { + const value = {} + const result = Value.Cast(T, value) + Assert.deepEqual(result, E) + }) + + it('Should upcast from array', () => { + const value = [1] + const result = Value.Cast(T, value) + Assert.deepEqual(result, E) + }) + + it('Should upcast from undefined', () => { + const value = undefined + const result = Value.Cast(T, value) + Assert.deepEqual(result, E) + }) + + it('Should upcast from null', () => { + const value = null + const result = Value.Cast(T, value) + Assert.deepEqual(result, E) + }) + + it('Should preserve', () => { + const value = { a: 'hello', b: 'world' } + const result = Value.Cast( + Type.Object({ + a: T, + b: T, + }), + value, + ) + Assert.deepEqual(result, { a: 'hello', b: 'world' }) + }) +}) diff --git a/test/runtime/value/cast/index.ts b/test/runtime/value/cast/index.ts index 74b79cd..58b1e82 100644 --- a/test/runtime/value/cast/index.ts +++ b/test/runtime/value/cast/index.ts @@ -2,6 +2,7 @@ import './any' import './array' import './boolean' import './convert' +import './custom' import './date' import './enum' import './integer' diff --git a/test/runtime/value/check/custom.ts b/test/runtime/value/check/custom.ts new file mode 100644 index 0000000..026b64e --- /dev/null +++ b/test/runtime/value/check/custom.ts @@ -0,0 +1,28 @@ +import { Value } from '@sinclair/typebox/value' +import { Custom } from '@sinclair/typebox/custom' +import { Type, Kind } from '@sinclair/typebox' +import { Assert } from '../../assert/index' + +describe('type/check/Custom', () => { + Custom.Set('BigInt', (value) => typeof value === 'bigint') + it('Should validate bigint', () => { + const T = Type.Unsafe({ [Kind]: 'BigInt' }) + Assert.deepEqual(Value.Check(T, 1n), true) + }) + it('Should not validate bigint', () => { + const T = Type.Unsafe({ [Kind]: 'BigInt' }) + Assert.deepEqual(Value.Check(T, 1), false) + }) + it('Should validate bigint nested', () => { + const T = Type.Object({ + x: Type.Unsafe({ [Kind]: 'BigInt' }), + }) + Assert.deepEqual(Value.Check(T, { x: 1n }), true) + }) + it('Should not validate bigint nested', () => { + const T = Type.Object({ + x: Type.Unsafe({ [Kind]: 'BigInt' }), + }) + Assert.deepEqual(Value.Check(T, { x: 1 }), false) + }) +}) diff --git a/test/runtime/value/check/index.ts b/test/runtime/value/check/index.ts index 26759b5..eb99c6e 100644 --- a/test/runtime/value/check/index.ts +++ b/test/runtime/value/check/index.ts @@ -1,6 +1,7 @@ import './any' import './array' import './boolean' +import './custom' import './date' import './enum' import './integer' diff --git a/test/runtime/value/create/custom.ts b/test/runtime/value/create/custom.ts new file mode 100644 index 0000000..2703379 --- /dev/null +++ b/test/runtime/value/create/custom.ts @@ -0,0 +1,18 @@ +import { Value } from '@sinclair/typebox/value' +import { Type, Kind } from '@sinclair/typebox' +import { Custom } from '@sinclair/typebox/custom' +import { Assert } from '../../assert/index' + +describe('value/create/Custom', () => { + it('Should create custom value with default', () => { + Custom.Set('CustomCreate1', (value) => true) + const T = Type.Unsafe({ [Kind]: 'CustomCreate1', default: 'hello' }) + Assert.deepEqual(Value.Create(T), 'hello') + }) + + it('Should throw when no default value is specified', () => { + Custom.Set('CustomCreate2', (value) => true) + const T = Type.Unsafe({ [Kind]: 'CustomCreate2' }) + Assert.throws(() => Value.Create(T)) + }) +}) diff --git a/test/runtime/value/create/index.ts b/test/runtime/value/create/index.ts index db38f47..2e06b6f 100644 --- a/test/runtime/value/create/index.ts +++ b/test/runtime/value/create/index.ts @@ -1,6 +1,7 @@ import './any' import './array' import './boolean' +import './custom' import './constructor' import './enum' import './function' diff --git a/tsconfig.json b/tsconfig.json index df793c1..41c97c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,9 @@ "@sinclair/typebox/conditional": [ "src/conditional/index.ts" ], + "@sinclair/typebox/custom": [ + "src/custom/index.ts" + ], "@sinclair/typebox/format": [ "src/format/index.ts" ],