diff --git a/packages/zod/src/v4/core/api.ts b/packages/zod/src/v4/core/api.ts index f0f55a7623..94d2aa0902 100644 --- a/packages/zod/src/v4/core/api.ts +++ b/packages/zod/src/v4/core/api.ts @@ -1629,3 +1629,17 @@ export function _stringFormat( const inst = new Class(def); return inst as any; } + +export function _with( + schema: T +): core.WithSchema { + const newSchema = Object.create(Object.getPrototypeOf(schema)); + + Object.assign(newSchema, schema); + + newSchema._zod = { + ...schema._zod, + }; + + return newSchema as core.WithSchema; +} diff --git a/packages/zod/src/v4/core/core.ts b/packages/zod/src/v4/core/core.ts index 3f1f637b4f..9d2a778d2c 100644 --- a/packages/zod/src/v4/core/core.ts +++ b/packages/zod/src/v4/core/core.ts @@ -87,6 +87,24 @@ export class $ZodAsyncError extends Error { export type input = T extends { _zod: { input: any } } ? T["_zod"]["input"] : unknown; export type output = T extends { _zod: { output: any } } ? T["_zod"]["output"] : unknown; +export interface WithOptions { + input?: TInput; + output?: TOutput; +} + +export type WithSchema = T extends { + _zod: infer TInternals; +} + ? TInternals extends schemas.$ZodTypeInternals + ? T & { + _zod: TInternals & { + input: [TInputOverride] extends [never] ? TInput : TInputOverride; + output: [TOutputOverride] extends [never] ? TOutput : TOutputOverride; + }; + } + : T + : T; + // Mk2 // export type input = T extends { _zod: { "~input": any } } // ? T["_zod"]["~input"] diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts index 93e2949dc2..3fec0f07ee 100644 --- a/packages/zod/src/v4/core/schemas.ts +++ b/packages/zod/src/v4/core/schemas.ts @@ -166,6 +166,8 @@ export interface $ZodType< > { _zod: Internals; "~standard": $ZodStandardSchema; + + with(): core.WithSchema; } export interface _$ZodType extends $ZodType { @@ -270,6 +272,18 @@ export const $ZodType: core.$constructor<$ZodType> = /*@__PURE__*/ core.$constru }; }); +$ZodType.prototype.with = function ( + this: $ZodType +): core.WithSchema { + const newSchema = Object.create(Object.getPrototypeOf(this)); + Object.assign(newSchema, this); + newSchema._zod = { + ...this._zod, + }; + + return newSchema as core.WithSchema; +}; + export { clone } from "./util.js"; ////////////////////////////////////////// diff --git a/packages/zod/src/v4/core/tests/with-integration.test.ts b/packages/zod/src/v4/core/tests/with-integration.test.ts new file mode 100644 index 0000000000..183f35b933 --- /dev/null +++ b/packages/zod/src/v4/core/tests/with-integration.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; +import * as z from "zod/v4"; + +describe(".with() method - Integration Tests", () => { + it("should work with tRPC-style branded types", () => { + type UserId = string & { __brand: "UserId" }; + type Email = string & { __brand: "Email" }; + + const UserIdSchema = z.string().uuid().with(); + const EmailSchema = z.string().email().with(); + + const UserSchema = z.object({ + id: UserIdSchema, + email: EmailSchema, + name: z.string().min(1), + }); + + type User = z.output; + + const user: User = { + id: "123e4567-e89b-12d3-a456-426614174000" as UserId, + email: "test@example.com" as Email, + name: "John Doe", + }; + + expect(user.id).toBeDefined(); + expect(user.email).toBeDefined(); + expect(user.name).toBe("John Doe"); + + const validData = { + id: "123e4567-e89b-12d3-a456-426614174000", + email: "test@example.com", + name: "John Doe", + }; + + const invalidData = { + id: "invalid-uuid", + email: "invalid-email", + name: "", + }; + + expect(UserSchema.safeParse(validData).success).toBe(true); + expect(UserSchema.safeParse(invalidData).success).toBe(false); + }); + + it("should work with complex nested schemas", () => { + type ProductId = string & { __brand: "ProductId" }; + type CategoryId = string & { __brand: "CategoryId" }; + + const ProductSchema = z.object({ + id: z.string().uuid().with(), + name: z.string().min(1), + price: z.number().positive(), + categoryId: z.string().uuid().with(), + tags: z.array(z.string()), + metadata: z.record(z.string(), z.unknown()).optional(), + }); + + type Product = z.output; + + const product: Product = { + id: "123e4567-e89b-12d3-a456-426614174000" as ProductId, + name: "Test Product", + price: 99.99, + categoryId: "987fcdeb-51d2-43a1-b456-426614174000" as CategoryId, + tags: ["electronics", "gadget"], + metadata: { color: "blue", weight: "1kg" }, + }; + + expect(product.id).toBeDefined(); + expect(product.categoryId).toBeDefined(); + + const validProduct = { + id: "123e4567-e89b-12d3-a456-426614174000", + name: "Test Product", + price: 99.99, + categoryId: "987fcdeb-51d2-43a1-b456-426614174000", + tags: ["electronics", "gadget"], + }; + + expect(ProductSchema.safeParse(validProduct).success).toBe(true); + + const invalidProduct = { + id: "invalid-uuid", + name: "", + price: -10, + categoryId: "invalid-uuid", + tags: "not-an-array", + }; + + expect(ProductSchema.safeParse(invalidProduct).success).toBe(false); + }); + + it("should work with transforms and branded types", () => { + type UppercaseString = string & { __brand: "Uppercase" }; + + const schema = z + .string() + .min(1) + .transform((val) => val.toUpperCase()) + .with(); + + const result = schema.parse("hello"); + expect(result).toBe("HELLO"); + + type Output = z.output; + const _typeCheck: Output = "HELLO" as UppercaseString; + expect(_typeCheck).toBe("HELLO"); + + expect(schema.safeParse("").success).toBe(false); + expect(schema.safeParse("test").success).toBe(true); + }); + + it("should work with unions and branded types", () => { + type StringId = string & { __brand: "StringId" }; + type NumberId = number & { __brand: "NumberId" }; + + const stringIdSchema = z.string().with(); + const numberIdSchema = z.number().with(); + + const unionSchema = z.union([stringIdSchema, numberIdSchema]); + + type UnionOutput = z.output; + + const stringResult: UnionOutput = "test" as StringId; + const numberResult: UnionOutput = 123 as NumberId; + + expect(stringResult).toBe("test"); + expect(numberResult).toBe(123); + + expect(unionSchema.safeParse("test").success).toBe(true); + expect(unionSchema.safeParse(123).success).toBe(true); + expect(unionSchema.safeParse(true).success).toBe(false); + }); + + it("should preserve all chaining capabilities", () => { + type CustomString = string & { __brand: "Custom" }; + + const schema = z + .string() + .min(5) + .max(20) + .regex(/^[a-zA-Z]+$/) + .with() + .optional() + .default("defaultValue" as CustomString); + + expect(schema.safeParse("hello").success).toBe(true); + expect(schema.safeParse("hi").success).toBe(false); + expect(schema.safeParse("verylongstringthatexceedslimit").success).toBe(false); + expect(schema.safeParse("hello123").success).toBe(false); + expect(schema.safeParse(undefined).success).toBe(true); + + const result = schema.parse(undefined); + expect(result).toBe("defaultValue"); + }); + + it("should work with lazy schemas", () => { + type UserId = string & { __brand: "UserId" }; + + const UserSchema = z.object({ + id: z.string().uuid().with(), + name: z.string().min(1), + }); + + const LazyUserSchema = z.lazy(() => UserSchema); + + const validUser = { + id: "123e4567-e89b-12d3-a456-426614174000", + name: "John Doe", + }; + + const invalidUser = { + id: "invalid-uuid", + name: "", + }; + + expect(LazyUserSchema.safeParse(validUser).success).toBe(true); + expect(LazyUserSchema.safeParse(invalidUser).success).toBe(false); + + type LazyOutput = z.output; + const user: LazyOutput = { + id: "123e4567-e89b-12d3-a456-426614174000" as UserId, + name: "John Doe", + }; + + expect(user.id).toBeDefined(); + expect(user.name).toBe("John Doe"); + }); +}); diff --git a/packages/zod/src/v4/core/tests/with.test.ts b/packages/zod/src/v4/core/tests/with.test.ts new file mode 100644 index 0000000000..7f1cb2b007 --- /dev/null +++ b/packages/zod/src/v4/core/tests/with.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import * as z from "zod/v4"; + +describe(".with() method", () => { + it("should override output type while preserving validation", () => { + const schema = z.string().min(5).with(); + + type Output = z.output; + const _typeCheck: Output = "CustomString" as any; + expect(_typeCheck).toBe("CustomString"); + + expect(schema.safeParse("hello").success).toBe(true); + expect(schema.safeParse("hi").success).toBe(false); + }); + + it("should override input type", () => { + const schema = z.string().with<"InputType", string>(); + + type Input = z.input; + const _typeCheck: Input = "InputType" as any; + expect(_typeCheck).toBe("InputType"); + }); + + it("should override both input and output types", () => { + const schema = z.string().with<"InputType", "OutputType">(); + + type Input = z.input; + type Output = z.output; + + const _inputCheck: Input = "InputType" as any; + const _outputCheck: Output = "OutputType" as any; + + expect(_inputCheck).toBe("InputType"); + expect(_outputCheck).toBe("OutputType"); + }); + + it("should work with branded types", () => { + const UserId = z.string().uuid().brand<"UserId">(); + const UserIdWithInput = UserId.with(); + + type Input = z.input; + type Output = z.output; + + const testInput: Input = "test" as any; + const testOutput: Output = "test" as any; + + expect(testInput).toBeDefined(); + expect(testOutput).toBeDefined(); + + const validUuid = "123e4567-e89b-12d3-a456-426614174000"; + const invalidUuid = "not-a-uuid"; + + expect(UserIdWithInput.safeParse(validUuid).success).toBe(true); + expect(UserIdWithInput.safeParse(invalidUuid).success).toBe(false); + }); + + it("should be chainable with other methods", () => { + const schema = z.string().min(5).with().optional(); + + expect(schema.safeParse("hello").success).toBe(true); + expect(schema.safeParse("hi").success).toBe(false); + expect(schema.safeParse(undefined).success).toBe(true); + }); + + it("should preserve validation logic with email", () => { + const emailSchema = z.string().email(); + const customEmailSchema = emailSchema.with(); + + expect(customEmailSchema.safeParse("valid@email.com").success).toBe(true); + expect(customEmailSchema.safeParse("invalid").success).toBe(false); + }); + + it("should work with transform", () => { + const schema = z + .string() + .transform((val) => val.length) + .with(); + + const result = schema.parse("hello"); + expect(result).toBe(5); + + type Output = z.output; + const _typeCheck: Output = 5 as any; + expect(typeof _typeCheck).toBe("number"); + }); + + it("should work with complex schemas (objects)", () => { + const userSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1), + }); + + const customUserSchema = userSchema.with< + { id: string; name: string }, + { id: string & { __brand: "UserId" }; name: string } + >(); + + const validUser = { id: "123e4567-e89b-12d3-a456-426614174000", name: "John" }; + const invalidUser = { id: "invalid-uuid", name: "John" }; + + expect(customUserSchema.safeParse(validUser).success).toBe(true); + expect(customUserSchema.safeParse(invalidUser).success).toBe(false); + }); + + it("should work with arrays", () => { + const stringArraySchema = z.array(z.string()); + const customArraySchema = stringArraySchema.with>(); + + const testArray = ["a", "b", "c"]; + expect(customArraySchema.safeParse(testArray).success).toBe(true); + expect(customArraySchema.safeParse([1, 2, 3]).success).toBe(false); + }); + + it("should handle never types correctly", () => { + const schema1 = z.string().with<"CustomInput">(); + type Input1 = z.input; + type Output1 = z.output; + + const _input1: Input1 = "CustomInput" as any; + const _output1: Output1 = "original" as any; + + expect(_input1).toBe("CustomInput"); + expect(_output1).toBe("original"); + + const schema2 = z.string().with(); + type Input2 = z.input; + type Output2 = z.output; + + const _input2: Input2 = "original" as any; + const _output2: Output2 = "CustomOutput" as any; + + expect(_input2).toBe("original"); + expect(_output2).toBe("CustomOutput"); + }); + + it("should not affect runtime behavior", () => { + const originalSchema = z.string().min(5); + const withSchema = originalSchema.with(); + + const testValue = "hello"; + const invalidValue = "hi"; + + expect(originalSchema.safeParse(testValue).success).toBe(withSchema.safeParse(testValue).success); + expect(originalSchema.safeParse(invalidValue).success).toBe(withSchema.safeParse(invalidValue).success); + }); + + it("should work with refinements", () => { + const schema = z + .string() + .refine((val) => val.includes("@"), "Must contain @") + .with(); + + expect(schema.safeParse("test@example.com").success).toBe(true); + expect(schema.safeParse("invalid").success).toBe(false); + }); +});