diff --git a/site/src/docs/typescript/basic-usage.md b/site/src/docs/typescript/basic-usage.md index 9bf343b2..e4871fc2 100644 --- a/site/src/docs/typescript/basic-usage.md +++ b/site/src/docs/typescript/basic-usage.md @@ -112,6 +112,10 @@ export interface SentimentResponse { } ``` +The validator type-checks against a minimal library rather than the full TypeScript standard library, since responses are always plain JSON. +Besides primitives, object types, arrays, and unions, schemas can use `ReadonlyArray`, `Partial`, `Required`, `Readonly`, `Pick`, `Omit`, `Record`, `Exclude`, `Extract`, `NonNullable`, `ReturnType`, `Awaited`, `Uppercase`, `Lowercase`, `Capitalize`, and `Uncapitalize`. +Runtime types such as `Date`, `Map`, or `Set` aren't JSON values and aren't available; describe their JSON representation instead (for example, an ISO date `string`). + It also means we will need to manually load up an input `.ts` file verbatim. ```ts diff --git a/typescript/src/ts/validate.ts b/typescript/src/ts/validate.ts index b2b0b9c5..0363db99 100644 --- a/typescript/src/ts/validate.ts +++ b/typescript/src/ts/validate.ts @@ -27,7 +27,25 @@ interface NewableFunction extends Function {} interface String { readonly length: number } interface Boolean { valueOf(): boolean } interface Number { valueOf(): number } -interface RegExp { test(string: string): boolean }`; +interface RegExp { test(string: string): boolean } +interface ReadonlyArray { readonly length: number, readonly [n: number]: T } +type Partial = { [P in keyof T]?: T[P] }; +type Required = { [P in keyof T]-?: T[P] }; +type Readonly = { readonly [P in keyof T]: T[P] }; +type Pick = { [P in K]: T[P] }; +type Record = { [P in K]: T }; +type Exclude = T extends U ? never : T; +type Extract = T extends U ? T : never; +type Omit = Pick>; +type NonNullable = T & {}; +interface PromiseLike { then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): PromiseLike } +interface Promise { then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): Promise; catch(onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null): Promise } +type ReturnType any> = T extends (...args: any) => infer R ? R : any; +type Awaited = T extends null | undefined ? T : T extends object & { then(onfulfilled: infer F, ...args: infer _): any } ? F extends ((value: infer V, ...args: infer _) => any) ? Awaited : never : T; +type Uppercase = intrinsic; +type Lowercase = intrinsic; +type Capitalize = intrinsic; +type Uncapitalize = intrinsic;`; const configFileName = "/tsconfig.json"; const libFileName = "/lib.d.ts"; diff --git a/typescript/test/validate.test.ts b/typescript/test/validate.test.ts index 31ef89fa..b74aa2d3 100644 --- a/typescript/test/validate.test.ts +++ b/typescript/test/validate.test.ts @@ -193,4 +193,105 @@ describe("createTypeScriptJsonValidator", () => { assert.ok(result.data.includes("const json: Point =")); }); }); + + describe("standard-library helper types", () => { + const helperSchema = ` +declare function makeConfig(): { port: number; host: string }; +declare function load(): Promise<{ id: string }>; +export interface Payload { + values: ReadonlyArray; + readonlySyntax: readonly number[]; + counts: Record; + metadata: Partial<{ name: string; size: number }>; + required: Required<{ id?: string }>; + frozen: Readonly<{ x: number }>; + picked: Pick<{ a: string; b: number }, "a">; + omitted: Omit<{ a: string; b: number }, "b">; + color: Exclude<"red" | "green" | "blue", "green">; + kind: Extract<"a" | "b" | 1, string>; + label: NonNullable; + config: ReturnType; + loaded: Awaited>; + nested: Awaited>>; + upper: Uppercase<"get" | "post">; + lower: Lowercase<"HeLLo">; + capitalized: Capitalize<"hello">; + uncapitalized: Uncapitalize<"Hello">; +}`; + const validPayload = { + values: ["a"], + readonlySyntax: [1], + counts: { a: 1 }, + metadata: {}, + required: { id: "x" }, + frozen: { x: 1 }, + picked: { a: "x" }, + omitted: { a: "x" }, + color: "red", + kind: "a", + label: "l", + config: { port: 1, host: "h" }, + loaded: { id: "x" }, + nested: 1, + upper: "POST", + lower: "hello", + capitalized: "Hello", + uncapitalized: "hello" + }; + + it("accepts a payload that satisfies every helper type", () => { + const v = validatorFor(helperSchema, "Payload"); + const result = v.validate(validPayload); + assert.ok(result.success, `expected validation to succeed, got: ${!result.success && result.message}`); + }); + + it("accepts the reproduction from issue #452", () => { + const v = validatorFor(` +export interface Payload { + values: ReadonlyArray; + counts: Record; + metadata: Partial<{ name: string }>; +}`, "Payload"); + const result = v.validate({ values: ["a"], counts: { a: 1 }, metadata: {} }); + assert.ok(result.success, `expected validation to succeed, got: ${!result.success && result.message}`); + }); + + const invalidCases: [string, object, RegExp][] = [ + ["ReadonlyArray element type", { values: [1] }, /'number' is not assignable to type 'string'/], + ["readonly array syntax element type", { readonlySyntax: ["x"] }, /'string' is not assignable to type 'number'/], + ["Record value type", { counts: { a: "1" } }, /'string' is not assignable to type 'number'/], + ["Partial property type", { metadata: { name: 1 } }, /'number' is not assignable to type 'string'/], + ["Required missing property", { required: {} }, /Property 'id' is missing/], + ["Readonly property type", { frozen: { x: "1" } }, /'string' is not assignable to type 'number'/], + ["Pick property type", { picked: { a: 1 } }, /'number' is not assignable to type 'string'/], + ["Omit removed property", { omitted: { a: "x", b: 1 } }, /'"?b"?' does not exist/], + ["Exclude removed member", { color: "green" }, /'"green"' is not assignable/], + ["Extract non-matching member", { kind: 1 }, /'number' is not assignable/], + ["NonNullable null", { label: null }, /'null' is not assignable to type 'string'/], + ["ReturnType property type", { config: { port: "1", host: "h" } }, /'string' is not assignable to type 'number'/], + ["Awaited property type", { loaded: { id: 1 } }, /'number' is not assignable to type 'string'/], + ["nested Awaited type", { nested: "1" }, /'string' is not assignable to type 'number'/], + ["Uppercase member", { upper: "PUT" }, /'"PUT"' is not assignable/], + ["Lowercase value", { lower: "HELLO" }, /'"HELLO"' is not assignable to type '"hello"'/], + ["Capitalize value", { capitalized: "hello" }, /'"hello"' is not assignable to type '"Hello"'/], + ["Uncapitalize value", { uncapitalized: "Hello" }, /'"Hello"' is not assignable to type '"hello"'/], + ]; + + it("rejects any JSON value for a Promise property", () => { + const v = validatorFor(`export interface Holder { value: Promise; }`, "Holder"); + for (const value of [1, {}, { then: "x" }]) { + const result = v.validate({ value }); + assert.ok(!result.success, `expected validation to fail for ${JSON.stringify(value)}`); + } + }); + + for (const [name, override, expected] of invalidCases) { + it(`rejects an invalid value for ${name}`, () => { + const v = validatorFor(helperSchema, "Payload"); + const result = v.validate({ ...validPayload, ...override }); + assert.ok(!result.success, "expected validation to fail"); + assert.match(result.message, expected); + }); + } + }); });