Skip to content

Commit

Permalink
Implement Standard Schema spec (#3850)
Browse files Browse the repository at this point in the history
* Implement Standard Schema

* Remove dep

* WIP

* Fix CI

* Update to latest standard-schema

* Add standard-schema/spec as devDep
  • Loading branch information
colinhacks authored Dec 10, 2024
1 parent 963386d commit 69a1798
Show file tree
Hide file tree
Showing 9 changed files with 560 additions and 6 deletions.
84 changes: 84 additions & 0 deletions deno/lib/__tests__/standard-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> = z.string();
const _s3: StandardSchemaV1<string, string> = z.string();
const _s4: StandardSchemaV1<unknown, string> = z.string();
[_s1, _s2, _s3, _s4];
});

test("type inference", () => {
const stringToNumber = z.string().transform((x) => x.length);
type input = StandardSchemaV1.InferInput<typeof stringToNumber>;
util.assertEqual<input, string>(true);
type output = StandardSchemaV1.InferOutput<typeof stringToNumber>;
util.assertEqual<output, number>(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");
}
});
119 changes: 119 additions & 0 deletions deno/lib/standard-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* The Standard Schema interface.
*/
export type StandardSchemaV1<Input = unknown, Output = Input> = {
/**
* The Standard Schema properties.
*/
readonly "~standard": StandardSchemaV1.Props<Input, Output>;
};

export declare namespace StandardSchemaV1 {
/**
* The Standard Schema properties interface.
*/
export interface Props<Input = unknown, Output = Input> {
/**
* 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<Output> | Promise<Result<Output>>;
/**
* Inferred types associated with the schema.
*/
readonly types?: Types<Input, Output> | undefined;
}

/**
* The result interface of the validate function.
*/
export type Result<Output> = SuccessResult<Output> | FailureResult;

/**
* The result interface if validation succeeds.
*/
export interface SuccessResult<Output> {
/**
* 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<Issue>;
}

/**
* 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<PropertyKey | PathSegment> | 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<Input = unknown, Output = Input> {
/**
* 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<Schema extends StandardSchemaV1> = NonNullable<
Schema["~standard"]["types"]
>["input"];

/**
* Infers the output type of a Standard Schema.
*/
export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
Schema["~standard"]["types"]
>["output"];

// biome-ignore lint/complexity/noUselessEmptyExport: needed for granular visibility control of TS namespace
export {};
}
60 changes: 59 additions & 1 deletion deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -169,7 +170,8 @@ export abstract class ZodType<
Output = any,
Def extends ZodTypeDef = ZodTypeDef,
Input = Output
> {
> implements StandardSchemaV1<Input, Output>
{
readonly _type!: Output;
readonly _output!: Output;
readonly _input!: Input;
Expand All @@ -179,6 +181,8 @@ export abstract class ZodType<
return this._def.description;
}

"~standard": StandardSchemaV1.Props<Input, Output>;

abstract _parse(input: ParseInput): ParseReturnType<Output>;

_getType(input: ParseInput): string {
Expand Down Expand Up @@ -262,6 +266,55 @@ export abstract class ZodType<
return handleResult(ctx, result);
}

"~validate"(
data: unknown
):
| StandardSchemaV1.Result<Output>
| Promise<StandardSchemaV1.Result<Output>> {
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<ParseParams>
Expand Down Expand Up @@ -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<this> {
Expand Down
23 changes: 19 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions playground.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
import { z } from "./src";

z;

const schema = z
.string()
.transform((input) => input || undefined)
.optional()
.default("default");

type Input = z.input<typeof schema>; // string | undefined
type Output = z.output<typeof schema>; // string

const result = schema.safeParse("");

console.log(result); // { success: true, data: undefined }
Loading

0 comments on commit 69a1798

Please sign in to comment.