Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions site/src/docs/typescript/basic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion typescript/src/ts/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> { readonly length: number, readonly [n: number]: T }
type Partial<T> = { [P in keyof T]?: T[P] };
type Required<T> = { [P in keyof T]-?: T[P] };
type Readonly<T> = { readonly [P in keyof T]: T[P] };
type Pick<T, K extends keyof T> = { [P in K]: T[P] };
type Record<K extends keyof any, T> = { [P in K]: T };
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type NonNullable<T> = T & {};
interface PromiseLike<T> { then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): PromiseLike<TResult1 | TResult2> }
interface Promise<T> { then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>; catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult> }
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
type Awaited<T> = T extends null | undefined ? T : T extends object & { then(onfulfilled: infer F, ...args: infer _): any } ? F extends ((value: infer V, ...args: infer _) => any) ? Awaited<V> : never : T;
type Uppercase<S extends string> = intrinsic;
type Lowercase<S extends string> = intrinsic;
type Capitalize<S extends string> = intrinsic;
type Uncapitalize<S extends string> = intrinsic;`;

const configFileName = "/tsconfig.json";
const libFileName = "/lib.d.ts";
Expand Down
101 changes: 101 additions & 0 deletions typescript/test/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
readonlySyntax: readonly number[];
counts: Record<string, number>;
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<string | null>;
config: ReturnType<typeof makeConfig>;
loaded: Awaited<ReturnType<typeof load>>;
nested: Awaited<Promise<Promise<number>>>;
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<string>;
counts: Record<string, number>;
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<number>; }`, "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);
});
}
});
});