diff --git a/.changeset/dirty-buckets-roll.md b/.changeset/dirty-buckets-roll.md new file mode 100644 index 00000000..dab81927 --- /dev/null +++ b/.changeset/dirty-buckets-roll.md @@ -0,0 +1,5 @@ +--- +'@withease/zod': patch +--- + +Increased package size limit diff --git a/.changeset/sharp-trees-obey.md b/.changeset/sharp-trees-obey.md new file mode 100644 index 00000000..85fef461 --- /dev/null +++ b/.changeset/sharp-trees-obey.md @@ -0,0 +1,5 @@ +--- +'@withease/zod': major +--- + +Initial release: Zod package adapter package diff --git a/.gitignore b/.gitignore index 587668d1..c7a37258 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ dist node_modules .DS_Store +.idea *.log # Playwright diff --git a/apps/website/docs/.vitepress/config.mjs b/apps/website/docs/.vitepress/config.mjs index b847f05a..741d5cf0 100644 --- a/apps/website/docs/.vitepress/config.mjs +++ b/apps/website/docs/.vitepress/config.mjs @@ -49,6 +49,7 @@ export default defineConfig({ { text: 'web-api', link: '/web-api/' }, { text: 'factories', link: '/factories/' }, { text: 'contracts', link: '/contracts/' }, + { text: 'zod', link: '/zod/' }, ], }, { text: 'Magazine', link: '/magazine/' }, @@ -140,6 +141,16 @@ export default defineConfig({ }, { text: 'APIs', link: '/contracts/api' }, ]), + ...createSidebar('zod', [ + { text: 'Get Started', link: '/zod/' }, + { text: 'Compatibility', link: '/zod/compatibility' }, + { + text: "API", + items: [ + { text: "zodContract", link: '/zod/api/contract/' } + ] + } + ]), '/magazine/': [ { text: 'Architecture', diff --git a/apps/website/docs/zod/api/contract/index.md b/apps/website/docs/zod/api/contract/index.md new file mode 100644 index 00000000..49ed6e2a --- /dev/null +++ b/apps/website/docs/zod/api/contract/index.md @@ -0,0 +1,54 @@ +# zodContract + +Adapter, initially created for [_Contracts_](/protocols/contract) that allows you to introduce data validation of the application. + +::: warning +Async schemas has not supported yet - related with [_Contracts protocol_](/protocols/contract) returned types:\ +Async schemas validation return Promise that can't be used with current [_Contracts protocol_](/protocols/contract) +::: + +## Usage as a _Contract_ + +`@withease/zod` is an adapter based on `@withease/contract` (used only [_Contract_ protocol](/protocols/contract) type) for full compatibility with Effector's ecosystem without additional interop. Just wrap your zod schema into adapter and use as usual [_Contract_](/protocols/contract). + +### Farfetched + +[Farfetched](https://ff.effector.dev) is the advanced data fetching tool for web applications based of Effector. It suggests to ensure that data received from the server is conforms desired [_Contract_](/protocols/contract). + +```ts +import { createQuery } from '@farfetched/core'; +import { z } from 'zod'; +import { zodContract } from '@withease/zod'; + +const CharacterSchema = z.object({ + id: z.string(), + name: z.string(), + status: StatusSchema, + species: z.string(), + type: z.string(), + gender: GenderSchema, + origin: z.object({ name: z.string(), url: z.string() }), + location: z.object({ name: z.string(), url: z.string() }), + image: z.string(), + episode: z.array(z.string()), +}); + +const characterQuery = createQuery({ + effect: createEffect(async (config: { id: number }) => { + const response = await fetch( + `https://rickandmortyapi.com/api/character/${config.id}` + ); + return response.json(); + }), + // after receiving data from the server + // check if it is conforms the Contract to ensure + // API does not return something unexpected + contract: zodContract(CharacterSchema), +}); +``` + +### Integration with other libraries + +Since _zodContract_ (`@withease/zod`) is compatible [_Contract_](/protocols/contract) protocol it can be used with any library that supports it. + +The full list of libraries that support _Contract_ protocol can be found [here](/protocols/contract). diff --git a/apps/website/docs/zod/compatibility.md b/apps/website/docs/zod/compatibility.md new file mode 100644 index 00000000..c33886c2 --- /dev/null +++ b/apps/website/docs/zod/compatibility.md @@ -0,0 +1,30 @@ +# Compatibility + +The best practice is to use a supported version of [Zod](https://zod.dev/). Preferably, opt for Zod@4. However, we recognize the need to support transitional versions to assist with migrations. + +## With zod@^4 + +You can use standard zod import. + +```ts +import { z } from 'zod'; +import { zodContract } from '@withease/zod'; +``` + +## With zod@^3.25.0 + +You still can use latest versions of zod@3 for migrations we support it. + +```ts +import { z } from 'zod/v3'; +import { zodContract } from '@withease/zod'; +``` + +## Before zod@3.25.0 + +You can use adapter from [Farfetched](https://ff.effector.dev/) + +```ts +import { z } from 'zod'; +import { zodContract } from '@farfetched/zod'; +``` diff --git a/apps/website/docs/zod/index.md b/apps/website/docs/zod/index.md new file mode 100644 index 00000000..ee7e1308 --- /dev/null +++ b/apps/website/docs/zod/index.md @@ -0,0 +1,39 @@ + + +# @withease/zod + +Small adapters (less than **{{maxSize}}** summary controlled by CI) for [Zod](https://zod.dev/) package support. + +## Installation + +First, you need to install package: + +::: code-group + +```sh [pnpm] +pnpm add @withease/zod +``` + +```sh [yarn] +yarn add @withease/zod +``` + +```sh [npm] +npm install @withease/zod +``` + +::: + +## Version compatibility + +Best way of usage - use a supported zod version. zod@4 is preferred.\ +But we understand the need to maintain a transitional versions to assist with migrations.\ +[Read more](./compatibility) + +## API + +- [zodContract](./api/contract/) diff --git a/packages/zod/README.md b/packages/zod/README.md new file mode 100644 index 00000000..b8b36636 --- /dev/null +++ b/packages/zod/README.md @@ -0,0 +1,3 @@ +# @withease/zod + +Read documentation [here](https://withease.effector.dev/zod/). diff --git a/packages/zod/package.json b/packages/zod/package.json new file mode 100644 index 00000000..81613951 --- /dev/null +++ b/packages/zod/package.json @@ -0,0 +1,50 @@ +{ + "name": "@withease/zod", + "version": "0.1.0", + "license": "MIT", + "repository": "https://github.com/effector/withease", + "scripts": { + "test:run": "vitest run --typecheck", + "test:watch": "vitest --typecheck", + "build": "vite build", + "size": "size-limit", + "publint": "node ../../tools/publint.mjs", + "typelint": "attw --pack" + }, + "peerDependencies": { + "@withease/contracts": "workspace:*", + "zod": "^3.25.0 || ^4.0.0" + }, + "devDependencies": { + "@withease/contracts": "workspace:*", + "zod": "^3.25.0 || ^4.0.0" + }, + "type": "module", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "main": "./dist/zod.cjs", + "module": "./dist/zod.js", + "types": "./dist/zod.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/zod.d.ts", + "default": "./dist/zod.js" + }, + "require": { + "types": "./dist/zod.d.cts", + "default": "./dist/zod.cjs" + } + } + }, + "size-limit": [ + { + "path": "./dist/zod.js", + "limit": "315 B" + } + ] +} diff --git a/packages/zod/src/__tests__/contract.test-d.ts b/packages/zod/src/__tests__/contract.test-d.ts new file mode 100644 index 00000000..7da907e6 --- /dev/null +++ b/packages/zod/src/__tests__/contract.test-d.ts @@ -0,0 +1,218 @@ +import { describe, test, expectTypeOf } from 'vitest'; +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 (zod v3)', () => { + test('string', () => { + const stringContract = zodContract(zodV3.string()); + + const smth: unknown = null; + + if (stringContract.isData(smth)) { + expectTypeOf(smth).toEqualTypeOf(); + expectTypeOf(smth).not.toEqualTypeOf(); + } + }); + + test('complex object', () => { + const complexContract = zodContract( + zodV3.tuple([ + zodV3.object({ + x: zodV3.number(), + y: zodV3.literal(false), + k: zodV3.set(zodV3.string()), + }), + zodV3.literal('literal'), + zodV3.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 = zodV3.object({ + branded: zodV3.string().brand<'Branded'>(), + }); + const brandedContract = zodContract(BrandedContainer); + + const smth: unknown = { branded: 'branded' }; + + if (brandedContract.isData(smth)) { + 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 new file mode 100644 index 00000000..025315a7 --- /dev/null +++ b/packages/zod/src/__tests__/contract.test.ts @@ -0,0 +1,363 @@ +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 (zod v3)', () => { + const zod = zod_v3; + + test('interprets invalid response as error', () => { + const contract = zodContract(zod.string()); + + expect(contract.getErrorMessages(2)).toMatchInlineSnapshot(` + [ + "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 set, 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 literal value, expected true, path: 0.y", + "Invalid set, expected set of strings, path: 0.k", + "Invalid literal value, expected "Uhm?", path: 1", + "Invalid literal value, 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(` + [ + "Expected number, received string, path: x", + "Expected string, received number, path: y.z", + "Expected boolean, received map, path: y.k.j", + ] + `); + }); +}); + +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/index.ts b/packages/zod/src/index.ts new file mode 100644 index 00000000..44de7711 --- /dev/null +++ b/packages/zod/src/index.ts @@ -0,0 +1 @@ +export { zodContract } from './zod_contract'; diff --git a/packages/zod/src/zod_contract.ts b/packages/zod/src/zod_contract.ts new file mode 100644 index 00000000..139d453b --- /dev/null +++ b/packages/zod/src/zod_contract.ts @@ -0,0 +1,67 @@ +import { + type ZodType as ZodTypeV3, + type ZodError as ZodErrorV3, + type TypeOf as TypeOfV3, +} from 'zod/v3'; +import { + type $ZodType as ZodTypeV4, + type output as TypeOfV4, + type $ZodError as ZodErrorV4, + safeParse, +} from 'zod/v4/core'; +import { type Contract } from '@withease/contracts'; + +type ZodAnyType = ZodTypeV3 | ZodTypeV4; +type ZodAnyError = ZodErrorV3 | ZodErrorV4; + +type Output = T extends ZodTypeV4 + ? TypeOfV4 + : T extends ZodTypeV3 + ? TypeOfV3 + : never; + +type ErrorTransformer = ( + issues: ZodAnyError +) => ReturnType['getErrorMessages']>; + +function isZodV4(schema: unknown): schema is ZodTypeV4 { + return !!schema && typeof schema === 'object' && '_zod' in schema; +} + +const standardErrorTransformer: ErrorTransformer = (error) => { + return error.issues.map((e) => { + const path = e.path.join('.'); + return path !== '' ? `${e.message}, path: ${path}` : e.message; + }); +}; + +/** + * Transforms Zod contracts for `data` to internal Contract. + * Any response which does not conform to `data` will be treated as error. + * + * @param {ZodTypeV3 | ZodTypeV4} data Zod Contract for valid data + */ +function zodContract( + data: 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 = isZodV4(data) + ? safeParse(data, raw) + : data.safeParse(raw); + if (validation.success) { + return []; + } + + return standardErrorTransformer(validation.error); + }, + }; +} + +export { zodContract }; diff --git a/packages/zod/tsconfig.json b/packages/zod/tsconfig.json new file mode 100644 index 00000000..65b1ff2c --- /dev/null +++ b/packages/zod/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "baseUrl": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/zod/vite.config.ts b/packages/zod/vite.config.ts new file mode 100644 index 00000000..983728ea --- /dev/null +++ b/packages/zod/vite.config.ts @@ -0,0 +1,22 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import dts from '../../tools/vite/types'; + +export default { + test: { + typecheck: { + ignoreSourceErrors: true, + }, + }, + plugins: [tsconfigPaths(), dts()], + build: { + lib: { + entry: 'src/index.ts', + name: '@withease/zod', + fileName: 'zod', + formats: ['es', 'cjs'], + }, + rollupOptions: { + external: ['zod', 'zod/v3', 'zod/v4/core'], + }, + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14dc7703..9485a514 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,14 @@ importers: packages/web-api: specifiers: {} + packages/zod: + specifiers: + '@withease/contracts': workspace:* + zod: ^3.25.0 || ^4.0.0 + devDependencies: + '@withease/contracts': link:../contracts + zod: 4.1.5 + packages: /@algolia/autocomplete-core/1.9.3_andexh5lxsyy34kfg3pekcyz5e: @@ -4696,3 +4704,7 @@ packages: optionalDependencies: commander: 9.5.0 dev: true + + /zod/4.1.5: + resolution: {integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==} + dev: true