From 61dc91e6118cf5f49fe6ab487e5c2015ca4ec51d Mon Sep 17 00:00:00 2001 From: Lav Kumar Date: Wed, 14 Aug 2024 13:15:01 +0530 Subject: [PATCH] feat: added support for type negation with z.not() and .not() method in schema validation #2862 --- README.md | 22 ++++++ README_ZH.md | 22 ++++++ deno/lib/README.md | 27 ++++++- deno/lib/__tests__/not.test.ts | 126 +++++++++++++++++++++++++++++++++ deno/lib/types.ts | 63 +++++++++++++++++ src/__tests__/not.test.ts | 125 ++++++++++++++++++++++++++++++++ src/types.ts | 63 +++++++++++++++++ 7 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 deno/lib/__tests__/not.test.ts create mode 100644 src/__tests__/not.test.ts diff --git a/README.md b/README.md index a5a7c54c2..b228a6ebc 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ - [Dates](#dates) - [Zod enums](#zod-enums) - [Native enums](#native-enums) +- [Not](#not) - [Optionals](#optionals) - [Nullables](#nullables) - [Objects](#objects) @@ -1133,6 +1134,27 @@ You can access the underlying object with the `.enum` property: FruitEnum.enum.Apple; // "apple" ``` +## Not + +You can use `z.not()` to create a schema that rejects a specific type. This wraps the schema in a `ZodNot` instance and returns the result. + +```ts +const schema = z.not(z.string()); + +schema.parse(1234); // => passes, as 1234 is not a string +schema.parse("hello"); // => throws an error, as "hello" is a string +``` + +For convenience, you can also call the `.not()` method on an existing schema. + +```ts +const schema = z.string().email().not(); + +schema.parse(1234); // => passes, as 1234 is not a string or a valid email +schema.parse("hello"); // => passes, as "hello" is not a valid email +schema.parse("user@example.com"); // => throws an error, as "user@example.com" is a valid email +``` + ## Optionals You can make any schema optional with `z.optional()`. This wraps the schema in a `ZodOptional` instance and returns the result. diff --git a/README_ZH.md b/README_ZH.md index 0c9ab46a0..741234f8d 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -65,6 +65,7 @@ - [.min/.max/.length](#minmaxlength) - [Unions](#unions) - [Discriminated unions](#discriminated-unions) +- [Not](#not) - [Optionals](#optionals) - [Nullables](#nullables) - [Enums](#enums) @@ -941,6 +942,27 @@ const B = z.discriminatedUnion("status", [ const AB = z.discriminatedUnion("status", [...A.options, ...B.options]); ``` +## Not + +你可以用 `z.not()` 来创建一个拒绝特定类型的模式。这个方法会将模式包装在 `ZodNot` 实例中并返回结果。 + +```ts +const schema = z.not(z.string()); + +schema.parse(1234); // => 通过,因为 1234 不是字符串 +schema.parse("hello"); // => 抛出错误,因为 "hello" 是字符串 +``` + +为了方便,你也可以在现有的模式上直接调用 `.not()` 方法。 + +```ts +const schema = z.string().email().not(); + +schema.parse(1234); // => 通过,因为 1234 不是字符串或有效的邮箱地址 +schema.parse("hello"); // => 通过,因为 "hello" 不是有效的邮箱地址 +schema.parse("user@example.com"); // => 抛出错误,因为 "user@example.com" 是一个有效的邮箱地址 +``` + ## Optionals 你可以用`z.optional()`使任何模式成为可选: diff --git a/deno/lib/README.md b/deno/lib/README.md index 84ca28ad4..b228a6ebc 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -83,6 +83,7 @@ - [Dates](#dates) - [Zod enums](#zod-enums) - [Native enums](#native-enums) +- [Not](#not) - [Optionals](#optionals) - [Nullables](#nullables) - [Objects](#objects) @@ -252,7 +253,7 @@ Sponsorship at any level is appreciated and encouraged. If you built a paid prod

- + speakeasy @@ -261,7 +262,7 @@ Sponsorship at any level is appreciated and encouraged. If you built a paid prod
SDKs & Terraform providers for your API
-
speakeasyapi.dev + speakeasy.com

@@ -490,6 +491,7 @@ There are a growing number of tools that are built atop or support Zod natively! - [`zod-prisma`](https://github.com/CarterGrimmeisen/zod-prisma): Generate Zod schemas from your Prisma schema. - [`Supervillain`](https://github.com/Southclaws/supervillain): Generate Zod schemas from your Go structs. - [`prisma-zod-generator`](https://github.com/omar-dulaimi/prisma-zod-generator): Emit Zod schemas from your Prisma schema. +- [`drizzle-zod`](https://orm.drizzle.team/docs/zod): Emit Zod schemas from your Drizzle schema. - [`prisma-trpc-generator`](https://github.com/omar-dulaimi/prisma-trpc-generator): Emit fully implemented tRPC routers and their validation schemas using Zod. - [`zod-prisma-types`](https://github.com/chrishoermann/zod-prisma-types) Create Zod types from your Prisma models. - [`quicktype`](https://app.quicktype.io/): Convert JSON objects and JSON schemas into Zod schemas. @@ -1132,6 +1134,27 @@ You can access the underlying object with the `.enum` property: FruitEnum.enum.Apple; // "apple" ``` +## Not + +You can use `z.not()` to create a schema that rejects a specific type. This wraps the schema in a `ZodNot` instance and returns the result. + +```ts +const schema = z.not(z.string()); + +schema.parse(1234); // => passes, as 1234 is not a string +schema.parse("hello"); // => throws an error, as "hello" is a string +``` + +For convenience, you can also call the `.not()` method on an existing schema. + +```ts +const schema = z.string().email().not(); + +schema.parse(1234); // => passes, as 1234 is not a string or a valid email +schema.parse("hello"); // => passes, as "hello" is not a valid email +schema.parse("user@example.com"); // => throws an error, as "user@example.com" is a valid email +``` + ## Optionals You can make any schema optional with `z.optional()`. This wraps the schema in a `ZodOptional` instance and returns the result. diff --git a/deno/lib/__tests__/not.test.ts b/deno/lib/__tests__/not.test.ts new file mode 100644 index 000000000..28c2401e0 --- /dev/null +++ b/deno/lib/__tests__/not.test.ts @@ -0,0 +1,126 @@ +// @ts-ignore TS6133 +import { expect } from "https://deno.land/x/expect@v0.2.6/mod.ts"; +const test = Deno.test; + +import * as z from "../index.ts"; + +/** Note + * Since z.never() allows no values, z.not(z.never()) should allow all values, including undefined. Hence, there are no failing test cases for notNever. + * There are no passing validation cases for notAny, as it should reject everything. +*/ + +// Set 1 +const str = z.string().not(); +const email = z.string().email().not(); +const num = z.number().not(); +const arr = z.array(z.string()).not(); +const rec = z.record(z.string()).not(); +const obj = z.object({ username: z.string() }).not(); + +// Set 2 +const bigInt = z.bigint().not(); +const bool = z.boolean().not(); +const date = z.date().not(); +const sym = z.symbol().not(); +const undef = z.undefined().not(); +const nul = z.null().not(); +const voidSchema = z.void().not(); +const anySchema = z.any().not(); +const unknownSchema = z.unknown().not(); +const neverSchema = z.never().not(); + +// Set 3 +const notString = z.not(z.string()); +const notNumber = z.not(z.number()); +const notStringArray = z.not(z.array(z.string())); +const notRecord = z.not(z.record(z.string())); +const notObject = z.not(z.object({ username: z.string() })); + +// Set 4 +const notBigInt = z.not(z.bigint()); +const notBoolean = z.not(z.boolean()); +const notDate = z.not(z.date()); +const notSymbol = z.not(z.symbol()); +const notUndefined = z.not(z.undefined()); +const notNull = z.not(z.null()); +const notVoid = z.not(z.void()); +const notAny = z.not(z.any()); +const notUnknown = z.not(z.unknown()); +const notNever = z.not(z.never()); + +test("passing validations", () => { + // Set 1 + str.parse(1234); + email.parse(1234); + email.parse("abcd"); + num.parse("1234"); + arr.parse([1234]); + rec.parse({ key: 1234 }); + obj.parse({ username: 1234 }); + + // Set 2 + bigInt.parse(10); + bool.parse(1234); + date.parse("not a date"); + sym.parse("1234"); + undef.parse(null); + nul.parse(undefined); + voidSchema.parse(null); + neverSchema.parse(undefined); // `z.never()` allows no values, so `notNever` can accept any value. + + // Set 3 + notString.parse(1234); + notNumber.parse("1234"); + notStringArray.parse([1234]); + notRecord.parse({ key: 1234 }); + notObject.parse({ username: 1234 }); + + // Set 4 + notBigInt.parse(10); + notBoolean.parse(1234); + notDate.parse("not a date"); + notSymbol.parse("1234"); + notUndefined.parse(null); + notNull.parse(undefined); + notVoid.parse(null); + notNever.parse(undefined); // `z.never()` allows no values, so `notNever` can accept any value. +}); + +test("failing validations", () => { + // Set 1 + expect(() => str.parse("1234")).toThrow(); + expect(() => email.parse("sample@gmail.com")).toThrow(); + expect(() => num.parse(1234)).toThrow(); + expect(() => arr.parse(["1234"])).toThrow(); + expect(() => rec.parse({ key: "value" })).toThrow(); + expect(() => obj.parse({ username: "1234" })).toThrow(); + + // Set 2 + expect(() => bigInt.parse(BigInt(10))).toThrow(); + expect(() => bool.parse(true)).toThrow(); + expect(() => date.parse(new Date())).toThrow(); + expect(() => sym.parse(Symbol("symbol"))).toThrow(); + expect(() => undef.parse(undefined)).toThrow(); + expect(() => nul.parse(null)).toThrow(); + expect(() => voidSchema.parse(undefined)).toThrow(); + expect(() => anySchema.parse(undefined)).toThrow(); // `z.any()` allows any value, so `notAny` should fail for any input. + expect(() => unknownSchema.parse(undefined)).toThrow(); // Same as `z.any()` + + // Set 3 + expect(() => notString.parse("1234")).toThrow(); + expect(() => notNumber.parse(1234)).toThrow(); + expect(() => notStringArray.parse(["1234"])).toThrow(); + expect(() => notRecord.parse({ key: "value" })).toThrow(); + expect(() => notObject.parse({ username: "1234" })).toThrow(); + + // Set 4 + expect(() => notBigInt.parse(BigInt(10))).toThrow(); + expect(() => notBoolean.parse(true)).toThrow(); + expect(() => notDate.parse(new Date())).toThrow(); + expect(() => notSymbol.parse(Symbol("symbol"))).toThrow(); + expect(() => notUndefined.parse(undefined)).toThrow(); + expect(() => notNull.parse(null)).toThrow(); + expect(() => notVoid.parse(undefined)).toThrow(); + expect(() => notAny.parse(undefined)).toThrow(); // `z.any()` allows any value, so `notAny` should fail for any input. + expect(() => notUnknown.parse(undefined)).toThrow(); // Same as `z.any()` +}); \ No newline at end of file diff --git a/deno/lib/types.ts b/deno/lib/types.ts index d634e524f..741b59f6f 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -410,6 +410,7 @@ export abstract class ZodType< this.nullable = this.nullable.bind(this); this.nullish = this.nullish.bind(this); this.array = this.array.bind(this); + this.not = this.not.bind(this); this.promise = this.promise.bind(this); this.or = this.or.bind(this); this.and = this.and.bind(this); @@ -436,6 +437,9 @@ export abstract class ZodType< array(): ZodArray { return ZodArray.create(this, this._def); } + not(): ZodNot { + return ZodNot.create(this, this._def); + } promise(): ZodPromise { return ZodPromise.create(this, this._def); } @@ -2295,6 +2299,62 @@ export class ZodArray< }; } +export interface ZodNotDef + extends ZodTypeDef { + item: T; + typeName: ZodFirstPartyTypeKind.ZodNot; +} + +export class ZodNot extends ZodType< + T["_output"] | null, + ZodNotDef, + T["_input"] | null +> { + _parse(input: ParseInput): ParseReturnType { + const { ctx } = this._processInputParams(input); + + const schema = this._def.item; + + if (!schema) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_arguments, + argumentsError: ctx.data, + }); + + return INVALID; + } + + const result = schema._parse( + new ParseInputLazyPath(ctx, ctx.data, ctx.path, 0) + ); + + if (!!result && String((result as any).status) == "valid") { + addIssueToContext(ctx, { + code: ZodIssueCode.custom, + message: "Invalid input", + }); + + return INVALID; + } + + return OK(input.data); + } + + static create = ( + schemas: T, + params?: RawCreateParams + ): ZodNot => { + if (!schemas) { + throw new Error("You must pass a schema to z.not( ... )"); + } + return new ZodNot({ + item: schemas, + typeName: ZodFirstPartyTypeKind.ZodNot, + ...processCreateParams(params), + }); + }; +} + export type ZodNonEmptyArray = ZodArray; ///////////////////////////////////////// @@ -5125,6 +5185,7 @@ export enum ZodFirstPartyTypeKind { ZodUndefined = "ZodUndefined", ZodNull = "ZodNull", ZodAny = "ZodAny", + ZodNot = "ZodNot", ZodUnknown = "ZodUnknown", ZodNever = "ZodNever", ZodVoid = "ZodVoid", @@ -5216,6 +5277,7 @@ const unknownType = ZodUnknown.create; const neverType = ZodNever.create; const voidType = ZodVoid.create; const arrayType = ZodArray.create; +const notType = ZodNot.create; const objectType = ZodObject.create; const strictObjectType = ZodObject.strictCreate; const unionType = ZodUnion.create; @@ -5274,6 +5336,7 @@ export { nanType as nan, nativeEnumType as nativeEnum, neverType as never, + notType as not, nullType as null, nullableType as nullable, numberType as number, diff --git a/src/__tests__/not.test.ts b/src/__tests__/not.test.ts new file mode 100644 index 000000000..467df496d --- /dev/null +++ b/src/__tests__/not.test.ts @@ -0,0 +1,125 @@ +// @ts-ignore TS6133 +import { expect, test } from "@jest/globals"; + +import * as z from "../index"; + +/** Note + * Since z.never() allows no values, z.not(z.never()) should allow all values, including undefined. Hence, there are no failing test cases for notNever. + * There are no passing validation cases for notAny, as it should reject everything. +*/ + +// Set 1 +const str = z.string().not(); +const email = z.string().email().not(); +const num = z.number().not(); +const arr = z.array(z.string()).not(); +const rec = z.record(z.string()).not(); +const obj = z.object({ username: z.string() }).not(); + +// Set 2 +const bigInt = z.bigint().not(); +const bool = z.boolean().not(); +const date = z.date().not(); +const sym = z.symbol().not(); +const undef = z.undefined().not(); +const nul = z.null().not(); +const voidSchema = z.void().not(); +const anySchema = z.any().not(); +const unknownSchema = z.unknown().not(); +const neverSchema = z.never().not(); + +// Set 3 +const notString = z.not(z.string()); +const notNumber = z.not(z.number()); +const notStringArray = z.not(z.array(z.string())); +const notRecord = z.not(z.record(z.string())); +const notObject = z.not(z.object({ username: z.string() })); + +// Set 4 +const notBigInt = z.not(z.bigint()); +const notBoolean = z.not(z.boolean()); +const notDate = z.not(z.date()); +const notSymbol = z.not(z.symbol()); +const notUndefined = z.not(z.undefined()); +const notNull = z.not(z.null()); +const notVoid = z.not(z.void()); +const notAny = z.not(z.any()); +const notUnknown = z.not(z.unknown()); +const notNever = z.not(z.never()); + +test("passing validations", () => { + // Set 1 + str.parse(1234); + email.parse(1234); + email.parse("abcd"); + num.parse("1234"); + arr.parse([1234]); + rec.parse({ key: 1234 }); + obj.parse({ username: 1234 }); + + // Set 2 + bigInt.parse(10); + bool.parse(1234); + date.parse("not a date"); + sym.parse("1234"); + undef.parse(null); + nul.parse(undefined); + voidSchema.parse(null); + neverSchema.parse(undefined); // `z.never()` allows no values, so `notNever` can accept any value. + + // Set 3 + notString.parse(1234); + notNumber.parse("1234"); + notStringArray.parse([1234]); + notRecord.parse({ key: 1234 }); + notObject.parse({ username: 1234 }); + + // Set 4 + notBigInt.parse(10); + notBoolean.parse(1234); + notDate.parse("not a date"); + notSymbol.parse("1234"); + notUndefined.parse(null); + notNull.parse(undefined); + notVoid.parse(null); + notNever.parse(undefined); // `z.never()` allows no values, so `notNever` can accept any value. +}); + +test("failing validations", () => { + // Set 1 + expect(() => str.parse("1234")).toThrow(); + expect(() => email.parse("sample@gmail.com")).toThrow(); + expect(() => num.parse(1234)).toThrow(); + expect(() => arr.parse(["1234"])).toThrow(); + expect(() => rec.parse({ key: "value" })).toThrow(); + expect(() => obj.parse({ username: "1234" })).toThrow(); + + // Set 2 + expect(() => bigInt.parse(BigInt(10))).toThrow(); + expect(() => bool.parse(true)).toThrow(); + expect(() => date.parse(new Date())).toThrow(); + expect(() => sym.parse(Symbol("symbol"))).toThrow(); + expect(() => undef.parse(undefined)).toThrow(); + expect(() => nul.parse(null)).toThrow(); + expect(() => voidSchema.parse(undefined)).toThrow(); + expect(() => anySchema.parse(undefined)).toThrow(); // `z.any()` allows any value, so `notAny` should fail for any input. + expect(() => unknownSchema.parse(undefined)).toThrow(); // Same as `z.any()` + + // Set 3 + expect(() => notString.parse("1234")).toThrow(); + expect(() => notNumber.parse(1234)).toThrow(); + expect(() => notStringArray.parse(["1234"])).toThrow(); + expect(() => notRecord.parse({ key: "value" })).toThrow(); + expect(() => notObject.parse({ username: "1234" })).toThrow(); + + // Set 4 + expect(() => notBigInt.parse(BigInt(10))).toThrow(); + expect(() => notBoolean.parse(true)).toThrow(); + expect(() => notDate.parse(new Date())).toThrow(); + expect(() => notSymbol.parse(Symbol("symbol"))).toThrow(); + expect(() => notUndefined.parse(undefined)).toThrow(); + expect(() => notNull.parse(null)).toThrow(); + expect(() => notVoid.parse(undefined)).toThrow(); + expect(() => notAny.parse(undefined)).toThrow(); // `z.any()` allows any value, so `notAny` should fail for any input. + expect(() => notUnknown.parse(undefined)).toThrow(); // Same as `z.any()` +}); \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 0767073c5..207b80e5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -410,6 +410,7 @@ export abstract class ZodType< this.nullable = this.nullable.bind(this); this.nullish = this.nullish.bind(this); this.array = this.array.bind(this); + this.not = this.not.bind(this); this.promise = this.promise.bind(this); this.or = this.or.bind(this); this.and = this.and.bind(this); @@ -436,6 +437,9 @@ export abstract class ZodType< array(): ZodArray { return ZodArray.create(this, this._def); } + not(): ZodNot { + return ZodNot.create(this, this._def); + } promise(): ZodPromise { return ZodPromise.create(this, this._def); } @@ -2295,6 +2299,62 @@ export class ZodArray< }; } +export interface ZodNotDef + extends ZodTypeDef { + item: T; + typeName: ZodFirstPartyTypeKind.ZodNot; +} + +export class ZodNot extends ZodType< + T["_output"] | null, + ZodNotDef, + T["_input"] | null +> { + _parse(input: ParseInput): ParseReturnType { + const { ctx } = this._processInputParams(input); + + const schema = this._def.item; + + if (!schema) { + addIssueToContext(ctx, { + code: ZodIssueCode.invalid_arguments, + argumentsError: ctx.data, + }); + + return INVALID; + } + + const result = schema._parse( + new ParseInputLazyPath(ctx, ctx.data, ctx.path, 0) + ); + + if (!!result && String((result as any).status) == "valid") { + addIssueToContext(ctx, { + code: ZodIssueCode.custom, + message: "Invalid input", + }); + + return INVALID; + } + + return OK(input.data); + } + + static create = ( + schemas: T, + params?: RawCreateParams + ): ZodNot => { + if (!schemas) { + throw new Error("You must pass a schema to z.not( ... )"); + } + return new ZodNot({ + item: schemas, + typeName: ZodFirstPartyTypeKind.ZodNot, + ...processCreateParams(params), + }); + }; +} + export type ZodNonEmptyArray = ZodArray; ///////////////////////////////////////// @@ -5125,6 +5185,7 @@ export enum ZodFirstPartyTypeKind { ZodUndefined = "ZodUndefined", ZodNull = "ZodNull", ZodAny = "ZodAny", + ZodNot = "ZodNot", ZodUnknown = "ZodUnknown", ZodNever = "ZodNever", ZodVoid = "ZodVoid", @@ -5216,6 +5277,7 @@ const unknownType = ZodUnknown.create; const neverType = ZodNever.create; const voidType = ZodVoid.create; const arrayType = ZodArray.create; +const notType = ZodNot.create; const objectType = ZodObject.create; const strictObjectType = ZodObject.strictCreate; const unionType = ZodUnion.create; @@ -5274,6 +5336,7 @@ export { nanType as nan, nativeEnumType as nativeEnum, neverType as never, + notType as not, nullType as null, nullableType as nullable, numberType as number,