From 2e8818e71dbc30abbfa9ec3f4a96e85c85d2c410 Mon Sep 17 00:00:00 2001 From: sinclairzx81 Date: Thu, 6 Jul 2023 10:09:47 +0900 Subject: [PATCH] Composite Type Optimization (#492) --- hammer.mjs | 7 ++++ package-lock.json | 4 +- package.json | 7 +++- readme.md | 58 +++++++++++++++++----------- src/typebox.ts | 33 ++++++++++------ test/static/composite.ts | 82 ++++++++++++++++++++++++++++++++-------- 6 files changed, 137 insertions(+), 54 deletions(-) diff --git a/hammer.mjs b/hammer.mjs index 61e8b06aa..5f37c1eb7 100644 --- a/hammer.mjs +++ b/hammer.mjs @@ -29,7 +29,14 @@ export async function benchmark() { // ------------------------------------------------------------------------------- // Test // ------------------------------------------------------------------------------- +export async function test_typescript() { + for (const version of ['4.9.5', '5.0.4', '5.1.3', '5.1.6', 'next', 'latest']) { + await shell(`npm install typescript@${version} --no-save`) + await test_static() + } +} export async function test_static() { + await shell(`tsc -v`) await shell(`tsc -p test/static/tsconfig.json --noEmit --strict`) } export async function test_runtime(filter = '') { diff --git a/package-lock.json b/package-lock.json index 31f90d3b1..7b9cb58d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sinclair/typebox", - "version": "0.29.3", + "version": "0.29.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@sinclair/typebox", - "version": "0.29.3", + "version": "0.29.4", "license": "MIT", "devDependencies": { "@sinclair/hammer": "^0.17.1", diff --git a/package.json b/package.json index 2971f5b17..1212620a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sinclair/typebox", - "version": "0.29.3", + "version": "0.29.4", "description": "JSONSchema Type Builder with Static Type Resolution for TypeScript", "keywords": [ "typescript", @@ -24,10 +24,13 @@ "url": "https://github.com/sinclairzx81/typebox" }, "scripts": { + "test:typescript": "hammer task test_typescript", + "test:static": "hammer task test_static", + "test:runtime": "hammer task test_runtime", + "test": "hammer task test", "clean": "hammer task clean", "format": "hammer task format", "start": "hammer task start", - "test": "hammer task test", "benchmark": "hammer task benchmark", "build": "hammer task build", "publish": "hammer task publish" diff --git a/readme.md b/readme.md index 68c792208..4bf1b532b 100644 --- a/readme.md +++ b/readme.md @@ -335,15 +335,15 @@ The following table lists the Standard TypeBox types. These types are fully comp │ │ │ } │ │ │ │ │ ├────────────────────────────────┼─────────────────────────────┼────────────────────────────────┤ -│ const T = Type.Composite([ │ type I = { │ const T = { │ +│ const T = Type.Composite([ │ type T = { │ const T = { │ │ Type.Object({ │ x: number │ type: 'object', │ │ x: Type.Number() │ } & { │ required: ['x', 'y'], │ │ }), │ y: number │ properties: { │ │ Type.Object({ │ } │ x: { │ │ y: Type.Number() │ │ type: 'number' │ -│ }) │ type T = { │ }, │ -│ ]) │ [K in keyof I]: I[K] │ y: { │ -│ │ } │ type: 'number' │ +│ }) │ │ }, │ +│ ]) │ │ y: { │ +│ │ │ type: 'number' │ │ │ │ } │ │ │ │ } │ │ │ │ } │ @@ -739,12 +739,12 @@ const R = Type.Ref(T) // const R = { ### Recursive Types -Recursive types are supported with `Type.Recursive` +Recursive types are supported with `Type.Recursive`. ```typescript -const Node = Type.Recursive(Node => Type.Object({ // const Node = { +const Node = Type.Recursive(This => Type.Object({ // const Node = { id: Type.String(), // $id: 'Node', - nodes: Type.Array(Node) // type: 'object', + nodes: Type.Array(This) // type: 'object', }), { $id: 'Node' }) // properties: { // id: { // type: 'string' @@ -776,38 +776,50 @@ function test(node: Node) { ### Conditional Types -Conditional types are supported with `Type.Extends`, `Type.Exclude` and `Type.Extract` +TypeBox supports conditional types with `Type.Extends`. This type will perform a structural assignment check for the first two parameters and return a `true` or `false` type from the second two parameters. The types `Type.Exclude` and `Type.Extract` are also supported. ```typescript // TypeScript type T0 = string extends number ? true : false // type T0 = false -type T1 = Extract // type T1 = number +type T1 = Extract<(1 | 2 | 3), 1> // type T1 = 1 -type T2 = Exclude // type T2 = string +type T2 = Exclude<(1 | 2 | 3), 1> // type T2 = 2 | 3 // TypeBox -const T0 = Type.Extends(Type.String(), Type.Number(), Type.Literal(true), Type.Literal(false)) - -const T1 = Type.Extract(Type.Union([Type.String(), Type.Number()]), Type.Number()) - -const T2 = Type.Exclude(Type.Union([Type.String(), Type.Number()]), Type.Number()) - - -type T0 = Static // type T0 = false +const T0 = Type.Extends( // const T0: TLiteral + Type.String(), + Type.Number(), + Type.Literal(true), + Type.Literal(false) +) -type T1 = Static // type T1 = number +const T1 = Type.Extract( // const T1: TLiteral<1> + Type.Union([ + Type.Literal(1), + Type.Literal(2), + Type.Literal(3) + ]), + Type.Literal(1) +) -type T2 = Static // type T2 = string +const T2 = Type.Exclude( // const T2: TUnion<[ + Type.Union([ // TLiteral<2>, + Type.Literal(1), // TLiteral<3> + Type.Literal(2), // ]> + Type.Literal(3) + ]), + Type.Literal(1) +) ``` ### Template Literal Types -TypeBox supports Template Literal types using `Type.TemplateLiteral`. These types can be created using a simple template DSL syntax, however more complex template literals can be created by passing an array of literal and union types. The examples below show the template DSL syntax. +TypeBox supports template literal types with `Type.TemplateLiteral`. This type implements an embedded DSL syntax to match the TypeScript template literal syntax. This type can also be composed by passing an array of union and literal types as parameters. The following example shows the DSL syntax. ```typescript // TypeScript @@ -853,7 +865,7 @@ const R = Type.Record(T, Type.String()) // const R = { ### Indexed Access Types -TypeBox supports Indexed Access types using `Type.Index`. This feature provides a consistent way to access property types without having to extract them from the underlying schema representation. Indexed accessors are supported for object and tuples, as well as nested union and intersect types. +TypeBox supports indexed access types using `Type.Index`. This type provides a consistent way to access interior property and array element types without having to extract them from the underlying schema representation. Indexed access types are supported for object, array, tuple, union and intersect types. ```typescript const T = Type.Object({ // const T = { @@ -888,7 +900,7 @@ const C = Type.Index(T, Type.KeyOf(T)) // const C = { ### Not Types -TypeBox has partial support for the JSON schema `not` keyword with `Type.Not`. This type is synonymous with the concept of a [negated types](https://github.com/microsoft/TypeScript/issues/4196) which are not supported in the TypeScript language. TypeBox does provide partial inference support via the intersection of `T & not U` (where all negated types infer as `unknown`). This can be used in the following context. +TypeBox provides support for the `not` keyword with `Type.Not`. This type is synonymous with [negated types](https://github.com/microsoft/TypeScript/issues/4196) which are not supported in the TypeScript language. Partial inference of this type can be attained via the intersection of `T & not U` (where all Not types infer as `unknown`). This approach can be used to narrow for broader types in the following context. ```typescript // TypeScript diff --git a/src/typebox.ts b/src/typebox.ts index f67705d80..0c9945cf7 100644 --- a/src/typebox.ts +++ b/src/typebox.ts @@ -241,16 +241,20 @@ export type TInstanceType> = T['retur // TComposite // -------------------------------------------------------------------------- // prettier-ignore -export type TCompositeReduce, K extends string[]> = K extends [infer L, ...infer R] - ? { [_ in Assert]: TIndexType> } & TCompositeReduce> - : {} +export type TCompositeKeys = T extends [infer L, ...infer R] + ? keyof Assert['properties'] | TCompositeKeys> + : never // prettier-ignore -export type TCompositeSelect> = UnionToTuple> extends infer K - ? Evaluate>> +export type TCompositeIndex, K extends string[]> = K extends [infer L, ...infer R] + ? { [_ in Assert]: TIndexType> } & TCompositeIndex> : {} // prettier-ignore -export type TComposite = TIntersect extends infer R - ? TObject>>> +export type TCompositeReduce = UnionToTuple> extends infer K + ? Evaluate, Assert>> + : {} // ^ indexed via intersection of T +// prettier-ignore +export type TComposite = TIntersect extends TIntersect + ? TObject> : TObject<{}> // -------------------------------------------------------------------------- // TConstructor @@ -640,12 +644,21 @@ export type StringFormatOption = | 'json-pointer' | 'relative-json-pointer' | 'regex' + | ({} & string) +// prettier-ignore +export type StringContentEncodingOption = + | '7bit' + | '8bit' + | 'binary' + | 'quoted-printable' + | 'base64' + | ({} & string) export interface StringOptions extends SchemaOptions { minLength?: number maxLength?: number pattern?: string - format?: string - contentEncoding?: '7bit' | '8bit' | 'binary' | 'quoted-printable' | 'base64' + format?: StringFormatOption + contentEncoding?: StringContentEncodingOption contentMediaType?: string } export interface TString extends TSchema, StringOptions { @@ -731,9 +744,7 @@ export interface TTemplateLiteral> = T extends TTuple ? AssertRest : never - export type TTupleInfer = T extends [infer L, ...infer R] ? [Static, P>, ...TTupleInfer, P>] : [] - export interface TTuple extends TSchema { [Kind]: 'Tuple' static: TTupleInfer // { [K in keyof T]: T[K] extends TSchema ? Static : T[K] } diff --git a/test/static/composite.ts b/test/static/composite.ts index b4399e20f..cf9bb88a0 100644 --- a/test/static/composite.ts +++ b/test/static/composite.ts @@ -1,7 +1,9 @@ import { Expect } from './assert' -import { Type, Static } from '@sinclair/typebox' +import { Type, TObject, TIntersect, TNumber, TBoolean } from '@sinclair/typebox' +// ---------------------------------------------------------------------------- // Overlapping - Non Varying +// ---------------------------------------------------------------------------- { const A = Type.Object({ A: Type.Number(), @@ -15,7 +17,9 @@ import { Type, Static } from '@sinclair/typebox' A: number }>() } +// ---------------------------------------------------------------------------- // Overlapping - Varying +// ---------------------------------------------------------------------------- { const A = Type.Object({ A: Type.Number(), @@ -29,7 +33,9 @@ import { Type, Static } from '@sinclair/typebox' A: never }>() } +// ---------------------------------------------------------------------------- // Overlapping Single Optional +// ---------------------------------------------------------------------------- { const A = Type.Object({ A: Type.Optional(Type.Number()), @@ -43,26 +49,50 @@ import { Type, Static } from '@sinclair/typebox' A: number }>() } +// ---------------------------------------------------------------------------- // Overlapping All Optional (Deferred) +// // Note for: https://github.com/sinclairzx81/typebox/issues/419 -// Determining if a composite property is optional requires a deep check for all properties gathered during a indexed access -// call. Currently, there isn't a trivial way to perform this check without running into possibly infinite instantiation issues. -// The optional check is only specific to overlapping properties. Singular properties will continue to work as expected. The -// rule is "if all composite properties for a key are optional, then the composite property is optional". Defer this test and -// document as minor breaking change. +// ---------------------------------------------------------------------------- { - // const A = Type.Object({ - // A: Type.Optional(Type.Number()), - // }) - // const B = Type.Object({ - // A: Type.Optional(Type.Number()), - // }) - // const T = Type.Composite([A, B]) - // Expect(T).ToInfer<{ - // A: number | undefined - // }>() + const A = Type.Object({ + A: Type.Optional(Type.Number()), + }) + const B = Type.Object({ + A: Type.Optional(Type.Number()), + }) + const T = Type.Composite([A, B]) + Expect(T).ToInfer<{ + A: number | undefined + }>() } +{ + const A = Type.Object({ + A: Type.Optional(Type.Number()), + }) + const B = Type.Object({ + A: Type.Number(), + }) + const T = Type.Composite([A, B]) + Expect(T).ToInfer<{ + A: number + }>() +} +{ + const A = Type.Object({ + A: Type.Number(), + }) + const B = Type.Object({ + A: Type.Number(), + }) + const T = Type.Composite([A, B]) + Expect(T).ToInfer<{ + A: number + }>() +} +// ---------------------------------------------------------------------------- // Distinct Properties +// ---------------------------------------------------------------------------- { const A = Type.Object({ A: Type.Number(), @@ -77,3 +107,23 @@ import { Type, Static } from '@sinclair/typebox' B: number }>() } +// ---------------------------------------------------------------------------- +// Intersection Quirk +// +// TypeScript has an evaluation quirk for the following case where the first +// type evaluates the sub property as never, but the second evaluates the +// entire type as never. There is probably a reason for this behavior, but +// TypeBox supports the former evaluation. +// +// { x: number } & { x: string } -> { x: number } & { x: string } => { x: never } +// { x: number } & { x: boolean } -> never -> ... +// ---------------------------------------------------------------------------- +{ + // prettier-ignore + const T: TObject<{ + x: TIntersect<[TNumber, TBoolean]> + }> = Type.Composite([ + Type.Object({ x: Type.Number() }), + Type.Object({ x: Type.Boolean() }) + ]) +}