Skip to content

Commit dcd9d68

Browse files
committed
Zod
1 parent f5c1944 commit dcd9d68

15 files changed

+372
-6
lines changed

apps/website/docs/.vitepress/config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export default defineConfig({
4949
{ text: 'web-api', link: '/web-api/' },
5050
{ text: 'factories', link: '/factories/' },
5151
{ text: 'contracts', link: '/contracts/' },
52+
{ text: 'zod', link: '/zod/' },
5253
],
5354
},
5455
{ text: 'Magazine', link: '/magazine/' },
@@ -140,6 +141,7 @@ export default defineConfig({
140141
},
141142
{ text: 'APIs', link: '/contracts/api' },
142143
]),
144+
...createSidebar('zod', [{ text: 'Get Started', link: '/zod/' }]),
143145
'/magazine/': [
144146
{
145147
text: 'Architecture',

apps/website/docs/index.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,20 @@ features:
3434
details: Web API bindings — network status, tab visibility, and more
3535
link: /web-api/
3636
linkText: Get Started
37+
- icon: 👩‍🏭
38+
title: factories
39+
details: Set of helpers to create factories in your application
40+
link: /factories/
41+
linkText: Get Started
3742
- icon: 📄
3843
title: contracts
3944
details: Extremely small library to validate data from external sources
4045
link: /contracts/
4146
linkText: Get Started
42-
- icon: 👩‍🏭
43-
title: factories
44-
details: Set of helpers to create factories in your application
45-
link: /factories/
47+
- icon: ♏️
48+
title: zod
49+
details: Compatibility layer for Zod and Contract-protocol
50+
link: /zod/
4651
linkText: Get Started
4752
---
4853

apps/website/docs/protocols/contract.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A rule to statically validate received data. Any object following the strict API
1313

1414
- [`@withease/contracts`](/contracts/)
1515
- [`@farfetched/runtypes`](https://farfetched.pages.dev/api/contracts/runtypes.html)
16-
- [`@farfetched/zod`](https://farfetched.pages.dev/api/contracts/zod.html)
16+
- [`@withease/zod`](/zod/)
1717
- [`@farfetched/io-ts`](https://farfetched.pages.dev/api/contracts/io-ts.html)
1818
- [`@farfetched/superstruct`](https://farfetched.pages.dev/api/contracts/superstruct.html)
1919
- [`@farfetched/typed-contracts`](https://farfetched.pages.dev/api/contracts/typed-contracts.html)

apps/website/docs/zod/index.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# zod
2+
3+
Compatibility layer for [Zod](https://zod.dev/) and [_Contract_](/protocols/contract). You need to install it and its peer dependencies before usage:
4+
5+
::: code-group
6+
7+
```sh [pnpm]
8+
pnpm install zod @withease/zod
9+
```
10+
11+
```sh [yarn]
12+
yarn add zod @withease/zod
13+
```
14+
15+
```sh [npm]
16+
npm install zod @withease/zod
17+
```
18+
19+
:::
20+
21+
## `zodContract`
22+
23+
Creates a [_Contract_](/protocols/contract) based on given `ZodType`.
24+
25+
```ts
26+
import { z } from 'zod';
27+
import { zodContract } from '@farfetched/zod';
28+
29+
const Asteroid = z.object({
30+
type: z.literal('asteroid'),
31+
mass: z.number(),
32+
});
33+
34+
const asteroidContract = zodContract(Asteroid);
35+
36+
/* typeof asteroidContract === Contract<
37+
* unknown, 👈 it accepts something unknown
38+
* { type: 'asteriod', mass: number }, 👈 and validates if it is an asteroid
39+
* >
40+
*/
41+
```

packages/zod/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# @withease/zod

packages/zod/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# @withease/zod
2+
3+
Read documentation [here](https://withease.effector.dev/zod/).

packages/zod/package.json

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"name": "@withease/zod",
3+
"version": "1.1.0",
4+
"license": "MIT",
5+
"scripts": {
6+
"test:run": "vitest run --typecheck",
7+
"test:watch": "vitest --typecheck",
8+
"build": "vite build",
9+
"size": "size-limit",
10+
"publint": "node ../../tools/publint.mjs",
11+
"typelint": "attw --pack"
12+
},
13+
"devDependencies": {
14+
"zod": "^3.19"
15+
},
16+
"peerDependencies": {
17+
"zod": "^3.19"
18+
},
19+
"type": "module",
20+
"publishConfig": {
21+
"access": "public"
22+
},
23+
"files": [
24+
"dist"
25+
],
26+
"main": "./dist/zod.cjs",
27+
"module": "./dist/zod.js",
28+
"types": "./dist/zod.d.ts",
29+
"exports": {
30+
".": {
31+
"import": {
32+
"types": "./dist/zod.d.ts",
33+
"default": "./dist/zod.js"
34+
},
35+
"require": {
36+
"types": "./dist/zod.d.cts",
37+
"default": "./dist/zod.cjs"
38+
}
39+
}
40+
},
41+
"size-limit": [
42+
{
43+
"path": "./dist/zod.js",
44+
"limit": "231 B"
45+
}
46+
]
47+
}

packages/zod/src/contract.test-d.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, test, expectTypeOf } from 'vitest';
2+
import { z as zod } from 'zod';
3+
4+
import { zodContract } from './index';
5+
6+
describe('zodContract', () => {
7+
test('string', () => {
8+
const stringContract = zodContract(zod.string());
9+
10+
const smth: unknown = null;
11+
12+
if (stringContract.isData(smth)) {
13+
expectTypeOf(smth).toEqualTypeOf<string>();
14+
expectTypeOf(smth).not.toEqualTypeOf<number>();
15+
}
16+
});
17+
18+
test('complex object', () => {
19+
const complexContract = zodContract(
20+
zod.tuple([
21+
zod.object({
22+
x: zod.number(),
23+
y: zod.literal(false),
24+
k: zod.set(zod.string()),
25+
}),
26+
zod.literal('literal'),
27+
zod.literal(42),
28+
])
29+
);
30+
31+
const smth: unknown = null;
32+
33+
if (complexContract.isData(smth)) {
34+
expectTypeOf(smth).toEqualTypeOf<
35+
[
36+
{
37+
x: number;
38+
y: false;
39+
k: Set<string>;
40+
},
41+
'literal',
42+
42
43+
]
44+
>();
45+
46+
expectTypeOf(smth).not.toEqualTypeOf<number>();
47+
48+
expectTypeOf(smth).not.toEqualTypeOf<
49+
[
50+
{
51+
x: string;
52+
y: false;
53+
k: Set<string>;
54+
},
55+
'literal',
56+
42
57+
]
58+
>();
59+
}
60+
});
61+
});

packages/zod/src/contract.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { z as zod } from 'zod';
2+
import { describe, test, expect } from 'vitest';
3+
4+
import { zodContract } from './index';
5+
6+
describe('zod/zodContract short', () => {
7+
test('interprets invalid response as error', () => {
8+
const contract = zodContract(zod.string());
9+
10+
expect(contract.getErrorMessages(2)).toMatchInlineSnapshot(`
11+
[
12+
"Expected string, received number",
13+
]
14+
`);
15+
});
16+
17+
test('passes valid data', () => {
18+
const contract = zodContract(zod.string());
19+
20+
expect(contract.getErrorMessages('foo')).toEqual([]);
21+
});
22+
23+
test('isData passes for valid data', () => {
24+
const contract = zodContract(
25+
zod.object({
26+
x: zod.number(),
27+
y: zod.string(),
28+
})
29+
);
30+
31+
expect(
32+
contract.isData({
33+
x: 42,
34+
y: 'answer',
35+
})
36+
).toEqual(true);
37+
});
38+
39+
test('isData does not pass for invalid data', () => {
40+
const contract = zodContract(
41+
zod.object({
42+
x: zod.number(),
43+
y: zod.string(),
44+
})
45+
);
46+
47+
expect(
48+
contract.isData({
49+
42: 'x',
50+
answer: 'y',
51+
})
52+
).toEqual(false);
53+
});
54+
55+
test('interprets complex invalid response as error', () => {
56+
const contract = zodContract(
57+
zod.tuple([
58+
zod.object({
59+
x: zod.number(),
60+
y: zod.literal(true),
61+
k: zod
62+
.set(zod.string())
63+
.nonempty('Invalid set, expected set of strings'),
64+
}),
65+
zod.literal('Uhm?'),
66+
zod.literal(42),
67+
])
68+
);
69+
70+
expect(
71+
contract.getErrorMessages([
72+
{
73+
x: 456,
74+
y: false,
75+
k: new Set(),
76+
},
77+
'Answer is:',
78+
'42',
79+
])
80+
).toMatchInlineSnapshot(`
81+
[
82+
"Invalid literal value, expected true, path: 0.y",
83+
"Invalid set, expected set of strings, path: 0.k",
84+
"Invalid literal value, expected "Uhm?", path: 1",
85+
"Invalid literal value, expected 42, path: 2",
86+
]
87+
`);
88+
});
89+
90+
test('path from original zod error included in final message', () => {
91+
const contract = zodContract(
92+
zod.object({
93+
x: zod.number(),
94+
y: zod.object({
95+
z: zod.string(),
96+
k: zod.object({
97+
j: zod.boolean(),
98+
}),
99+
}),
100+
})
101+
);
102+
103+
expect(
104+
contract.getErrorMessages({
105+
x: '42',
106+
y: {
107+
z: 123,
108+
k: {
109+
j: new Map(),
110+
},
111+
},
112+
})
113+
).toMatchInlineSnapshot(`
114+
[
115+
"Expected number, received string, path: x",
116+
"Expected string, received number, path: y.z",
117+
"Expected boolean, received map, path: y.k.j",
118+
]
119+
`);
120+
});
121+
});

packages/zod/src/contract_protocol.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* A _Contract_ is a type that allows to check if a value is conform to a given structure.
3+
*/
4+
export type Contract<Raw, Data extends Raw> = {
5+
/**
6+
* Checks if Raw is Data
7+
*/
8+
isData: (prepared: Raw) => prepared is Data;
9+
/**
10+
* - empty array is dedicated for valid response
11+
* - array of string with validation errors for invalidDataError
12+
*/
13+
getErrorMessages: (prepared: Raw) => string[];
14+
};

packages/zod/src/index.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { type ZodType } from 'zod';
2+
import { type Contract } from './contract_protocol';
3+
4+
/**
5+
* Transforms Zod contracts for `data` to internal Contract.
6+
* Any response which does not conform to `data` will be treated as error.
7+
*
8+
* @param {ZodType} data Zod Contract for valid data
9+
*/
10+
export function zodContract<D>(data: ZodType<D>): Contract<unknown, D> {
11+
function isData(prepared: unknown): prepared is D {
12+
return data.safeParse(prepared).success;
13+
}
14+
15+
return {
16+
isData,
17+
getErrorMessages(raw) {
18+
const validation = data.safeParse(raw);
19+
if (validation.success) {
20+
return [];
21+
}
22+
23+
return validation.error.errors.map((e) => {
24+
const path = e.path.join('.');
25+
return path !== '' ? `${e.message}, path: ${path}` : e.message;
26+
});
27+
},
28+
};
29+
}

packages/zod/tsconfig.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"compilerOptions": {
4+
"declaration": true,
5+
"types": ["node"],
6+
"outDir": "dist",
7+
"rootDir": "src",
8+
"baseUrl": "src"
9+
},
10+
"include": ["src/**/*.ts"]
11+
}

0 commit comments

Comments
 (0)