diff --git a/packages/zod/package.json b/packages/zod/package.json index edbe9725..123dbfad 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -7,12 +7,12 @@ "license": "MIT", "repository": "https://github.com/igorkamyshev/farfetched", "devDependencies": { - "zod": "^3.19", - "@farfetched/core": "workspace:*" + "@farfetched/core": "workspace:*", + "zod": "^3.25.67" }, "peerDependencies": { - "zod": "^3.19", - "@farfetched/core": "workspace:*" + "@farfetched/core": "workspace:*", + "zod": "^3.25.0 || ^4.0.0" }, "scripts": { "test:run": "vitest run --typecheck", diff --git a/packages/zod/src/__tests__/contract.test-d.ts b/packages/zod/src/__tests__/contract.test-d.ts index c9d3b854..ef8c3781 100644 --- a/packages/zod/src/__tests__/contract.test-d.ts +++ b/packages/zod/src/__tests__/contract.test-d.ts @@ -1,11 +1,13 @@ import { describe, test, expectTypeOf } from 'vitest'; -import { z as zod } from 'zod'; +import { z as zodV3 } from 'zod/v3'; +import { z as zodV4 } from 'zod/v4'; +import { z as zodV4mini } from 'zod/v4-mini'; import { zodContract } from '../zod_contract'; -describe('zodContract', () => { +describe('zodContract (zod v3)', () => { test('string', () => { - const stringContract = zodContract(zod.string()); + const stringContract = zodContract(zodV3.string()); const smth: unknown = null; @@ -17,14 +19,14 @@ describe('zodContract', () => { test('complex object', () => { const complexContract = zodContract( - zod.tuple([ - zod.object({ - x: zod.number(), - y: zod.literal(false), - k: zod.set(zod.string()), + zodV3.tuple([ + zodV3.object({ + x: zodV3.number(), + y: zodV3.literal(false), + k: zodV3.set(zodV3.string()), }), - zod.literal('literal'), - zod.literal(42), + zodV3.literal('literal'), + zodV3.literal(42), ]) ); @@ -60,15 +62,157 @@ describe('zodContract', () => { }); test('branded type', () => { - const BrandedContainer = zod.object({ - branded: zod.string().brand<'Branded'>(), + const BrandedContainer = zodV3.object({ + branded: zodV3.string().brand<'Branded'>(), }); const brandedContract = zodContract(BrandedContainer); const smth: unknown = { branded: 'branded' }; if (brandedContract.isData(smth)) { - expectTypeOf(smth).toEqualTypeOf>(); + expectTypeOf(smth).toEqualTypeOf>(); + } + }); +}); + +describe('zodContract (zod v4)', () => { + test('string', () => { + const stringContract = zodContract(zodV4.string()); + + const smth: unknown = null; + + if (stringContract.isData(smth)) { + expectTypeOf(smth).toEqualTypeOf(); + expectTypeOf(smth).not.toEqualTypeOf(); + } + }); + + test('complex object', () => { + const complexContract = zodContract( + zodV4.tuple([ + zodV4.object({ + x: zodV4.number(), + y: zodV4.literal(false), + k: zodV4.set(zodV4.string()), + }), + zodV4.literal('literal'), + zodV4.literal(42), + ]) + ); + + const smth: unknown = null; + + if (complexContract.isData(smth)) { + expectTypeOf(smth).toEqualTypeOf< + [ + { + x: number; + y: false; + k: Set; + }, + 'literal', + 42, + ] + >(); + + expectTypeOf(smth).not.toEqualTypeOf(); + + expectTypeOf(smth).not.toEqualTypeOf< + [ + { + x: string; + y: false; + k: Set; + }, + 'literal', + 42, + ] + >(); + } + }); + + test('branded type', () => { + const BrandedContainer = zodV4.object({ + branded: zodV4.string().brand<'Branded'>(), + }); + const brandedContract = zodContract(BrandedContainer); + + const smth: unknown = { branded: 'branded' }; + + if (brandedContract.isData(smth)) { + expectTypeOf(smth).toEqualTypeOf>(); + } + }); +}); + +describe('zodContract (zod v4-mini)', () => { + test('string', () => { + const stringContract = zodContract(zodV4mini.string()); + + const smth: unknown = null; + + if (stringContract.isData(smth)) { + expectTypeOf(smth).toEqualTypeOf(); + expectTypeOf(smth).not.toEqualTypeOf(); + } + }); + + test('complex object', () => { + const complexContract = zodContract( + zodV4mini.tuple([ + zodV4mini.object({ + x: zodV4mini.number(), + y: zodV4mini.literal(false), + k: zodV4mini.set(zodV4mini.string()), + }), + zodV4mini.literal('literal'), + zodV4mini.literal(42), + ]) + ); + + const smth: unknown = null; + + if (complexContract.isData(smth)) { + expectTypeOf(smth).toEqualTypeOf< + [ + { + x: number; + y: false; + k: Set; + }, + 'literal', + 42, + ] + >(); + + expectTypeOf(smth).not.toEqualTypeOf(); + + expectTypeOf(smth).not.toEqualTypeOf< + [ + { + x: string; + y: false; + k: Set; + }, + 'literal', + 42, + ] + >(); + } + }); + + test('branded type', () => { + const BrandedContainer = zodV4mini.object({ + branded: zodV4mini.string().brand<'Branded'>(), + }); + const brandedContract = zodContract(BrandedContainer); + + const smth: unknown = { branded: 'branded' }; + + if (brandedContract.isData(smth)) { + expectTypeOf(smth).toEqualTypeOf< + zodV4mini.infer + >(); } }); }); diff --git a/packages/zod/src/__tests__/contract.test.ts b/packages/zod/src/__tests__/contract.test.ts index 08c85734..025315a7 100644 --- a/packages/zod/src/__tests__/contract.test.ts +++ b/packages/zod/src/__tests__/contract.test.ts @@ -1,9 +1,13 @@ -import { z as zod } from 'zod'; +import { z as zod_v3 } from 'zod/v3'; +import { z as zod_v4 } from 'zod/v4'; +import { z as zod_v4mini } from 'zod/v4-mini'; import { describe, test, expect } from 'vitest'; import { zodContract } from '../zod_contract'; -describe('zod/zodContract short', () => { +describe('zod/zodContract short (zod v3)', () => { + const zod = zod_v3; + test('interprets invalid response as error', () => { const contract = zodContract(zod.string()); @@ -119,3 +123,241 @@ describe('zod/zodContract short', () => { `); }); }); + +describe('zod/zodContract short (zod v4)', () => { + const zod = zod_v4; + + test('interprets invalid response as error', () => { + const contract = zodContract(zod.string()); + + expect(contract.getErrorMessages(2)).toMatchInlineSnapshot(` + [ + "Invalid input: expected string, received number", + ] + `); + }); + + test('passes valid data', () => { + const contract = zodContract(zod.string()); + + expect(contract.getErrorMessages('foo')).toEqual([]); + }); + + test('isData passes for valid data', () => { + const contract = zodContract( + zod.object({ + x: zod.number(), + y: zod.string(), + }) + ); + + expect( + contract.isData({ + x: 42, + y: 'answer', + }) + ).toEqual(true); + }); + + test('isData does not pass for invalid data', () => { + const contract = zodContract( + zod.object({ + x: zod.number(), + y: zod.string(), + }) + ); + + expect( + contract.isData({ + 42: 'x', + answer: 'y', + }) + ).toEqual(false); + }); + + test('interprets complex invalid response as error', () => { + const contract = zodContract( + zod.tuple([ + zod.object({ + x: zod.number(), + y: zod.literal(true), + k: zod + .set(zod.string()) + .nonempty('Invalid input: expected set of strings'), + }), + zod.literal('Uhm?'), + zod.literal(42), + ]) + ); + + expect( + contract.getErrorMessages([ + { + x: 456, + y: false, + k: new Set(), + }, + 'Answer is:', + '42', + ]) + ).toMatchInlineSnapshot(` + [ + "Invalid input: expected true, path: 0.y", + "Invalid input: expected set of strings, path: 0.k", + "Invalid input: expected "Uhm?", path: 1", + "Invalid input: expected 42, path: 2", + ] + `); + }); + + test('path from original zod error included in final message', () => { + const contract = zodContract( + zod.object({ + x: zod.number(), + y: zod.object({ + z: zod.string(), + k: zod.object({ + j: zod.boolean(), + }), + }), + }) + ); + + expect( + contract.getErrorMessages({ + x: '42', + y: { + z: 123, + k: { + j: new Map(), + }, + }, + }) + ).toMatchInlineSnapshot(` + [ + "Invalid input: expected number, received string, path: x", + "Invalid input: expected string, received number, path: y.z", + "Invalid input: expected boolean, received Map, path: y.k.j", + ] + `); + }); +}); + +describe('zod/zodContract short (zod v4-mini)', () => { + const zod = zod_v4mini; + + test('interprets invalid response as error', () => { + const contract = zodContract(zod.string()); + + expect(contract.getErrorMessages(2)).toMatchInlineSnapshot(` + [ + "Invalid input: expected string, received number", + ] + `); + }); + + test('passes valid data', () => { + const contract = zodContract(zod.string()); + + expect(contract.getErrorMessages('foo')).toEqual([]); + }); + + test('isData passes for valid data', () => { + const contract = zodContract( + zod.object({ + x: zod.number(), + y: zod.string(), + }) + ); + + expect( + contract.isData({ + x: 42, + y: 'answer', + }) + ).toEqual(true); + }); + + test('isData does not pass for invalid data', () => { + const contract = zodContract( + zod.object({ + x: zod.number(), + y: zod.string(), + }) + ); + + expect( + contract.isData({ + 42: 'x', + answer: 'y', + }) + ).toEqual(false); + }); + + test('interprets complex invalid response as error', () => { + const contract = zodContract( + zod.tuple([ + zod.object({ + x: zod.number(), + y: zod.literal(true), + k: zod.set(zod.string(), { + error: 'Invalid input: expected set of strings', + }), + }), + zod.literal('Uhm?'), + zod.literal(42), + ]) + ); + + expect( + contract.getErrorMessages([ + { + x: 456, + y: false, + k: new Map(), + }, + 'Answer is:', + '42', + ]) + ).toMatchInlineSnapshot(` + [ + "Invalid input: expected true, path: 0.y", + "Invalid input: expected set of strings, path: 0.k", + "Invalid input: expected "Uhm?", path: 1", + "Invalid input: expected 42, path: 2", + ] + `); + }); + + test('path from original zod error included in final message', () => { + const contract = zodContract( + zod.object({ + x: zod.number(), + y: zod.object({ + z: zod.string(), + k: zod.object({ + j: zod.boolean(), + }), + }), + }) + ); + + expect( + contract.getErrorMessages({ + x: '42', + y: { + z: 123, + k: { + j: new Map(), + }, + }, + }) + ).toMatchInlineSnapshot(` + [ + "Invalid input: expected number, received string, path: x", + "Invalid input: expected string, received number, path: y.z", + "Invalid input: expected boolean, received Map, path: y.k.j", + ] + `); + }); +}); diff --git a/packages/zod/src/zod_contract.ts b/packages/zod/src/zod_contract.ts index 7e69a995..ea14816b 100644 --- a/packages/zod/src/zod_contract.ts +++ b/packages/zod/src/zod_contract.ts @@ -1,28 +1,46 @@ -import { type ZodType, type TypeOf } from 'zod'; +import { type ZodType as ZodTypeV3, type TypeOf as TypeOfV3 } from 'zod/v3'; +import { + type $ZodType as ZodTypeV4, + type output as TypeOfV4, + safeParse, +} from 'zod/v4/core'; import { type Contract } from '@farfetched/core'; +type ZodAnyType = ZodTypeV3 | ZodTypeV4; +type Output = T extends ZodTypeV4 + ? TypeOfV4 + : T extends ZodTypeV3 + ? TypeOfV3 + : never; +function isZodV4(schema: unknown): schema is ZodTypeV4 { + return !!schema && typeof schema === 'object' && '_zod' in schema; +} + /** * Transforms Zod contracts for `data` to internal Contract. * Any response which does not conform to `data` will be treated as error. * - * @param {ZodType} data Zod Contract for valid data + * @param {ZodTypeV3 | ZodTypeV4} data Zod Contract for valid data */ -function zodContract>( +function zodContract( data: T -): Contract> { - function isData(prepared: unknown): prepared is T { +): Contract> { + function isData(prepared: unknown): prepared is Output { + if (isZodV4(data)) return safeParse(data, prepared).success; return data.safeParse(prepared).success; } return { isData, getErrorMessages(raw) { - const validation = data.safeParse(raw); + const validation = isZodV4(data) + ? safeParse(data, raw) + : data.safeParse(raw); if (validation.success) { return []; } - return validation.error.errors.map((e) => { + return validation.error.issues.map((e) => { const path = e.path.join('.'); return path !== '' ? `${e.message}, path: ${path}` : e.message; }); diff --git a/packages/zod/vite.config.ts b/packages/zod/vite.config.ts index 01bd9593..e32b7362 100644 --- a/packages/zod/vite.config.ts +++ b/packages/zod/vite.config.ts @@ -20,7 +20,7 @@ export default { formats: ['es', 'cjs'], }, rollupOptions: { - external: ['zod'], + external: ['zod', 'zod/v3', 'zod/v4/core'], }, }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 641fb684..6a6c7c18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,10 +258,10 @@ importers: packages/zod: specifiers: '@farfetched/core': workspace:* - zod: ^3.19 + zod: ^3.25.67 devDependencies: '@farfetched/core': link:../core - zod: 3.19.1 + zod: 3.25.67 packages: @@ -7292,3 +7292,8 @@ packages: /zod/3.19.1: resolution: {integrity: sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==} + dev: false + + /zod/3.25.67: + resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==} + dev: true