diff --git a/deno/lib/__tests__/standard-schema.test.ts b/deno/lib/__tests__/standard-schema.test.ts new file mode 100644 index 000000000..065315c52 --- /dev/null +++ b/deno/lib/__tests__/standard-schema.test.ts @@ -0,0 +1,84 @@ +// @ts-ignore TS6133 +import { expect } from "https://deno.land/x/expect@v0.2.6/mod.ts"; +const test = Deno.test; +import { util } from "../helpers/util.ts"; + +import * as z from "../index.ts"; + +import type { StandardSchemaV1 } from "@standard-schema/spec"; + +test("assignability", () => { + const _s1: StandardSchemaV1 = z.string(); + const _s2: StandardSchemaV1 = z.string(); + const _s3: StandardSchemaV1 = z.string(); + const _s4: StandardSchemaV1 = z.string(); + [_s1, _s2, _s3, _s4]; +}); + +test("type inference", () => { + const stringToNumber = z.string().transform((x) => x.length); + type input = StandardSchemaV1.InferInput; + util.assertEqual(true); + type output = StandardSchemaV1.InferOutput; + util.assertEqual(true); +}); + +test("valid parse", () => { + const schema = z.string(); + const result = schema["~standard"]["validate"]("hello"); + if (result instanceof Promise) { + throw new Error("Expected sync result"); + } + expect(result.issues).toEqual(undefined); + if (result.issues) { + throw new Error("Expected no issues"); + } else { + expect(result.value).toEqual("hello"); + } +}); + +test("invalid parse", () => { + const schema = z.string(); + const result = schema["~standard"]["validate"](1234); + if (result instanceof Promise) { + throw new Error("Expected sync result"); + } + expect(result.issues).toBeDefined(); + if (!result.issues) { + throw new Error("Expected issues"); + } + expect(result.issues.length).toEqual(1); + expect(result.issues[0].path).toEqual([]); +}); + +test("valid parse async", async () => { + const schema = z.string().refine(async () => true); + const _result = schema["~standard"]["validate"]("hello"); + if (_result instanceof Promise) { + const result = await _result; + expect(result.issues).toEqual(undefined); + if (result.issues) { + throw new Error("Expected no issues"); + } else { + expect(result.value).toEqual("hello"); + } + } else { + throw new Error("Expected async result"); + } +}); + +test("invalid parse async", async () => { + const schema = z.string().refine(async () => false); + const _result = schema["~standard"]["validate"]("hello"); + if (_result instanceof Promise) { + const result = await _result; + expect(result.issues).toBeDefined(); + if (!result.issues) { + throw new Error("Expected issues"); + } + expect(result.issues.length).toEqual(1); + expect(result.issues[0].path).toEqual([]); + } else { + throw new Error("Expected async result"); + } +}); diff --git a/deno/lib/standard-schema.ts b/deno/lib/standard-schema.ts new file mode 100644 index 000000000..111888e57 --- /dev/null +++ b/deno/lib/standard-schema.ts @@ -0,0 +1,119 @@ +/** + * The Standard Schema interface. + */ +export type StandardSchemaV1 = { + /** + * The Standard Schema properties. + */ + readonly "~standard": StandardSchemaV1.Props; +}; + +export declare namespace StandardSchemaV1 { + /** + * The Standard Schema properties interface. + */ + export interface Props { + /** + * The version number of the standard. + */ + readonly version: 1; + /** + * The vendor name of the schema library. + */ + readonly vendor: string; + /** + * Validates unknown input values. + */ + readonly validate: ( + value: unknown + ) => Result | Promise>; + /** + * Inferred types associated with the schema. + */ + readonly types?: Types | undefined; + } + + /** + * The result interface of the validate function. + */ + export type Result = SuccessResult | FailureResult; + + /** + * The result interface if validation succeeds. + */ + export interface SuccessResult { + /** + * The typed output value. + */ + readonly value: Output; + /** + * The non-existent issues. + */ + readonly issues?: undefined; + } + + /** + * The result interface if validation fails. + */ + export interface FailureResult { + /** + * The issues of failed validation. + */ + readonly issues: ReadonlyArray; + } + + /** + * The issue interface of the failure output. + */ + export interface Issue { + /** + * The error message of the issue. + */ + readonly message: string; + /** + * The path of the issue, if any. + */ + readonly path?: ReadonlyArray | undefined; + } + + /** + * The path segment interface of the issue. + */ + export interface PathSegment { + /** + * The key representing a path segment. + */ + readonly key: PropertyKey; + } + + /** + * The Standard Schema types interface. + */ + export interface Types { + /** + * The input type of the schema. + */ + readonly input: Input; + /** + * The output type of the schema. + */ + readonly output: Output; + } + + /** + * Infers the input type of a Standard Schema. + */ + export type InferInput = NonNullable< + Schema["~standard"]["types"] + >["input"]; + + /** + * Infers the output type of a Standard Schema. + */ + export type InferOutput = NonNullable< + Schema["~standard"]["types"] + >["output"]; + + // biome-ignore lint/complexity/noUselessEmptyExport: needed for granular visibility control of TS namespace + export {}; +} diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 5d020d278..fc3e8b21b 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -23,6 +23,7 @@ import { import { partialUtil } from "./helpers/partialUtil.ts"; import { Primitive } from "./helpers/typeAliases.ts"; import { getParsedType, objectUtil, util, ZodParsedType } from "./helpers/util.ts"; +import type { StandardSchemaV1 } from "./standard-schema.ts"; import { IssueData, StringValidation, @@ -169,7 +170,8 @@ export abstract class ZodType< Output = any, Def extends ZodTypeDef = ZodTypeDef, Input = Output -> { +> implements StandardSchemaV1 +{ readonly _type!: Output; readonly _output!: Output; readonly _input!: Input; @@ -179,6 +181,8 @@ export abstract class ZodType< return this._def.description; } + "~standard": StandardSchemaV1.Props; + abstract _parse(input: ParseInput): ParseReturnType; _getType(input: ParseInput): string { @@ -262,6 +266,55 @@ export abstract class ZodType< return handleResult(ctx, result); } + "~validate"( + data: unknown + ): + | StandardSchemaV1.Result + | Promise> { + const ctx: ParseContext = { + common: { + issues: [], + async: !!(this["~standard"] as any).async, + }, + path: [], + schemaErrorMap: this._def.errorMap, + parent: null, + data, + parsedType: getParsedType(data), + }; + + if (!(this["~standard"] as any).async) { + try { + const result = this._parseSync({ data, path: [], parent: ctx }); + return isValid(result) + ? { + value: result.value, + } + : { + issues: ctx.common.issues, + }; + } catch (err: any) { + if ((err as Error)?.message?.toLowerCase()?.includes("encountered")) { + (this["~standard"] as any).async = true; + } + (ctx as any).common = { + issues: [], + async: true, + }; + } + } + + return this._parseAsync({ data, path: [], parent: ctx }).then((result) => + isValid(result) + ? { + value: result.value, + } + : { + issues: ctx.common.issues, + } + ); + } + async parseAsync( data: unknown, params?: Partial @@ -422,6 +475,11 @@ export abstract class ZodType< this.readonly = this.readonly.bind(this); this.isNullable = this.isNullable.bind(this); this.isOptional = this.isOptional.bind(this); + this["~standard"] = { + version: 1, + vendor: "zod", + validate: (data) => this["~validate"](data), + }; } optional(): ZodOptional { diff --git a/package.json b/package.json index 639030673..d6ecb8918 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@babel/preset-typescript": "^7.22.5", "@jest/globals": "^29.4.3", "@rollup/plugin-typescript": "^8.2.0", + "@standard-schema/spec": "^1.0.0-beta.4", "@swc/core": "^1.3.66", "@swc/jest": "^0.2.26", "@types/benchmark": "^2.1.0", @@ -59,14 +60,28 @@ "url": "https://github.com/colinhacks/zod/issues" }, "description": "TypeScript-first schema declaration and validation library with static type inference", - "files": ["/lib", "/index.d.ts"], + "files": [ + "/lib", + "/index.d.ts" + ], "funding": "https://github.com/sponsors/colinhacks", "homepage": "https://zod.dev", - "keywords": ["typescript", "schema", "validation", "type", "inference"], + "keywords": [ + "typescript", + "schema", + "validation", + "type", + "inference" + ], "license": "MIT", "lint-staged": { - "src/*.ts": ["eslint --cache --fix", "prettier --ignore-unknown --write"], - "*.md": ["prettier --ignore-unknown --write"] + "src/*.ts": [ + "eslint --cache --fix", + "prettier --ignore-unknown --write" + ], + "*.md": [ + "prettier --ignore-unknown --write" + ] }, "scripts": { "prettier:check": "prettier --check src/**/*.ts deno/lib/**/*.ts *.md --no-error-on-unmatched-pattern", diff --git a/playground.ts b/playground.ts index 4e01473b6..bca434a98 100644 --- a/playground.ts +++ b/playground.ts @@ -1,3 +1,16 @@ import { z } from "./src"; z; + +const schema = z + .string() + .transform((input) => input || undefined) + .optional() + .default("default"); + +type Input = z.input; // string | undefined +type Output = z.output; // string + +const result = schema.safeParse(""); + +console.log(result); // { success: true, data: undefined } diff --git a/src/__tests__/standard-schema.test.ts b/src/__tests__/standard-schema.test.ts new file mode 100644 index 000000000..8f67cce76 --- /dev/null +++ b/src/__tests__/standard-schema.test.ts @@ -0,0 +1,83 @@ +// @ts-ignore TS6133 +import { expect, test } from "@jest/globals"; +import { util } from "../helpers/util"; + +import * as z from "../index"; + +import type { StandardSchemaV1 } from "@standard-schema/spec"; + +test("assignability", () => { + const _s1: StandardSchemaV1 = z.string(); + const _s2: StandardSchemaV1 = z.string(); + const _s3: StandardSchemaV1 = z.string(); + const _s4: StandardSchemaV1 = z.string(); + [_s1, _s2, _s3, _s4]; +}); + +test("type inference", () => { + const stringToNumber = z.string().transform((x) => x.length); + type input = StandardSchemaV1.InferInput; + util.assertEqual(true); + type output = StandardSchemaV1.InferOutput; + util.assertEqual(true); +}); + +test("valid parse", () => { + const schema = z.string(); + const result = schema["~standard"]["validate"]("hello"); + if (result instanceof Promise) { + throw new Error("Expected sync result"); + } + expect(result.issues).toEqual(undefined); + if (result.issues) { + throw new Error("Expected no issues"); + } else { + expect(result.value).toEqual("hello"); + } +}); + +test("invalid parse", () => { + const schema = z.string(); + const result = schema["~standard"]["validate"](1234); + if (result instanceof Promise) { + throw new Error("Expected sync result"); + } + expect(result.issues).toBeDefined(); + if (!result.issues) { + throw new Error("Expected issues"); + } + expect(result.issues.length).toEqual(1); + expect(result.issues[0].path).toEqual([]); +}); + +test("valid parse async", async () => { + const schema = z.string().refine(async () => true); + const _result = schema["~standard"]["validate"]("hello"); + if (_result instanceof Promise) { + const result = await _result; + expect(result.issues).toEqual(undefined); + if (result.issues) { + throw new Error("Expected no issues"); + } else { + expect(result.value).toEqual("hello"); + } + } else { + throw new Error("Expected async result"); + } +}); + +test("invalid parse async", async () => { + const schema = z.string().refine(async () => false); + const _result = schema["~standard"]["validate"]("hello"); + if (_result instanceof Promise) { + const result = await _result; + expect(result.issues).toBeDefined(); + if (!result.issues) { + throw new Error("Expected issues"); + } + expect(result.issues.length).toEqual(1); + expect(result.issues[0].path).toEqual([]); + } else { + throw new Error("Expected async result"); + } +}); diff --git a/src/standard-schema.ts b/src/standard-schema.ts new file mode 100644 index 000000000..111888e57 --- /dev/null +++ b/src/standard-schema.ts @@ -0,0 +1,119 @@ +/** + * The Standard Schema interface. + */ +export type StandardSchemaV1 = { + /** + * The Standard Schema properties. + */ + readonly "~standard": StandardSchemaV1.Props; +}; + +export declare namespace StandardSchemaV1 { + /** + * The Standard Schema properties interface. + */ + export interface Props { + /** + * The version number of the standard. + */ + readonly version: 1; + /** + * The vendor name of the schema library. + */ + readonly vendor: string; + /** + * Validates unknown input values. + */ + readonly validate: ( + value: unknown + ) => Result | Promise>; + /** + * Inferred types associated with the schema. + */ + readonly types?: Types | undefined; + } + + /** + * The result interface of the validate function. + */ + export type Result = SuccessResult | FailureResult; + + /** + * The result interface if validation succeeds. + */ + export interface SuccessResult { + /** + * The typed output value. + */ + readonly value: Output; + /** + * The non-existent issues. + */ + readonly issues?: undefined; + } + + /** + * The result interface if validation fails. + */ + export interface FailureResult { + /** + * The issues of failed validation. + */ + readonly issues: ReadonlyArray; + } + + /** + * The issue interface of the failure output. + */ + export interface Issue { + /** + * The error message of the issue. + */ + readonly message: string; + /** + * The path of the issue, if any. + */ + readonly path?: ReadonlyArray | undefined; + } + + /** + * The path segment interface of the issue. + */ + export interface PathSegment { + /** + * The key representing a path segment. + */ + readonly key: PropertyKey; + } + + /** + * The Standard Schema types interface. + */ + export interface Types { + /** + * The input type of the schema. + */ + readonly input: Input; + /** + * The output type of the schema. + */ + readonly output: Output; + } + + /** + * Infers the input type of a Standard Schema. + */ + export type InferInput = NonNullable< + Schema["~standard"]["types"] + >["input"]; + + /** + * Infers the output type of a Standard Schema. + */ + export type InferOutput = NonNullable< + Schema["~standard"]["types"] + >["output"]; + + // biome-ignore lint/complexity/noUselessEmptyExport: needed for granular visibility control of TS namespace + export {}; +} diff --git a/src/types.ts b/src/types.ts index f3730ae14..b2cd5488a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,7 @@ import { import { partialUtil } from "./helpers/partialUtil"; import { Primitive } from "./helpers/typeAliases"; import { getParsedType, objectUtil, util, ZodParsedType } from "./helpers/util"; +import type { StandardSchemaV1 } from "./standard-schema"; import { IssueData, StringValidation, @@ -169,7 +170,8 @@ export abstract class ZodType< Output = any, Def extends ZodTypeDef = ZodTypeDef, Input = Output -> { +> implements StandardSchemaV1 +{ readonly _type!: Output; readonly _output!: Output; readonly _input!: Input; @@ -179,6 +181,8 @@ export abstract class ZodType< return this._def.description; } + "~standard": StandardSchemaV1.Props; + abstract _parse(input: ParseInput): ParseReturnType; _getType(input: ParseInput): string { @@ -262,6 +266,55 @@ export abstract class ZodType< return handleResult(ctx, result); } + "~validate"( + data: unknown + ): + | StandardSchemaV1.Result + | Promise> { + const ctx: ParseContext = { + common: { + issues: [], + async: !!(this["~standard"] as any).async, + }, + path: [], + schemaErrorMap: this._def.errorMap, + parent: null, + data, + parsedType: getParsedType(data), + }; + + if (!(this["~standard"] as any).async) { + try { + const result = this._parseSync({ data, path: [], parent: ctx }); + return isValid(result) + ? { + value: result.value, + } + : { + issues: ctx.common.issues, + }; + } catch (err: any) { + if ((err as Error)?.message?.toLowerCase()?.includes("encountered")) { + (this["~standard"] as any).async = true; + } + (ctx as any).common = { + issues: [], + async: true, + }; + } + } + + return this._parseAsync({ data, path: [], parent: ctx }).then((result) => + isValid(result) + ? { + value: result.value, + } + : { + issues: ctx.common.issues, + } + ); + } + async parseAsync( data: unknown, params?: Partial @@ -422,6 +475,11 @@ export abstract class ZodType< this.readonly = this.readonly.bind(this); this.isNullable = this.isNullable.bind(this); this.isOptional = this.isOptional.bind(this); + this["~standard"] = { + version: 1, + vendor: "zod", + validate: (data) => this["~validate"](data), + }; } optional(): ZodOptional { diff --git a/yarn.lock b/yarn.lock index 5c18678b4..2016a4c07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2881,6 +2881,11 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@standard-schema/spec@^1.0.0-beta.4": + version "1.0.0-beta.4" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.0.0-beta.4.tgz#62f520109add3eb016004098363bfee0678dd1ec" + integrity sha512-d3IxtzLo7P1oZ8s8YNvxzBUXRXojSut8pbPrTYtzsc5sn4+53jVqbk66pQerSZbZSJZQux6LkclB/+8IDordHg== + "@swc/core-darwin-arm64@1.4.8": version "1.4.8" resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.4.8.tgz#2fb702e209310c2da2bc712b0757c011b583a60d"