Skip to content

Commit

Permalink
Zod
Browse files Browse the repository at this point in the history
  • Loading branch information
igorkamyshev committed Sep 3, 2024
1 parent f5c1944 commit dcd9d68
Show file tree
Hide file tree
Showing 15 changed files with 372 additions and 6 deletions.
2 changes: 2 additions & 0 deletions apps/website/docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/' },
Expand Down Expand Up @@ -140,6 +141,7 @@ export default defineConfig({
},
{ text: 'APIs', link: '/contracts/api' },
]),
...createSidebar('zod', [{ text: 'Get Started', link: '/zod/' }]),
'/magazine/': [
{
text: 'Architecture',
Expand Down
13 changes: 9 additions & 4 deletions apps/website/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,20 @@ features:
details: Web API bindings — network status, tab visibility, and more
link: /web-api/
linkText: Get Started
- icon: 👩‍🏭
title: factories
details: Set of helpers to create factories in your application
link: /factories/
linkText: Get Started
- icon: 📄
title: contracts
details: Extremely small library to validate data from external sources
link: /contracts/
linkText: Get Started
- icon: 👩‍🏭
title: factories
details: Set of helpers to create factories in your application
link: /factories/
- icon: ♏️
title: zod
details: Compatibility layer for Zod and Contract-protocol
link: /zod/
linkText: Get Started
---

Expand Down
2 changes: 1 addition & 1 deletion apps/website/docs/protocols/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A rule to statically validate received data. Any object following the strict API

- [`@withease/contracts`](/contracts/)
- [`@farfetched/runtypes`](https://farfetched.pages.dev/api/contracts/runtypes.html)
- [`@farfetched/zod`](https://farfetched.pages.dev/api/contracts/zod.html)
- [`@withease/zod`](/zod/)
- [`@farfetched/io-ts`](https://farfetched.pages.dev/api/contracts/io-ts.html)
- [`@farfetched/superstruct`](https://farfetched.pages.dev/api/contracts/superstruct.html)
- [`@farfetched/typed-contracts`](https://farfetched.pages.dev/api/contracts/typed-contracts.html)
Expand Down
41 changes: 41 additions & 0 deletions apps/website/docs/zod/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# zod

Compatibility layer for [Zod](https://zod.dev/) and [_Contract_](/protocols/contract). You need to install it and its peer dependencies before usage:

::: code-group

```sh [pnpm]
pnpm install zod @withease/zod
```

```sh [yarn]
yarn add zod @withease/zod
```

```sh [npm]
npm install zod @withease/zod
```

:::

## `zodContract`

Creates a [_Contract_](/protocols/contract) based on given `ZodType`.

```ts
import { z } from 'zod';
import { zodContract } from '@farfetched/zod';

const Asteroid = z.object({
type: z.literal('asteroid'),
mass: z.number(),
});

const asteroidContract = zodContract(Asteroid);

/* typeof asteroidContract === Contract<
* unknown, 👈 it accepts something unknown
* { type: 'asteriod', mass: number }, 👈 and validates if it is an asteroid
* >
*/
```
1 change: 1 addition & 0 deletions packages/zod/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @withease/zod
3 changes: 3 additions & 0 deletions packages/zod/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @withease/zod

Read documentation [here](https://withease.effector.dev/zod/).
47 changes: 47 additions & 0 deletions packages/zod/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "@withease/zod",
"version": "1.1.0",
"license": "MIT",
"scripts": {
"test:run": "vitest run --typecheck",
"test:watch": "vitest --typecheck",
"build": "vite build",
"size": "size-limit",
"publint": "node ../../tools/publint.mjs",
"typelint": "attw --pack"
},
"devDependencies": {
"zod": "^3.19"
},
"peerDependencies": {
"zod": "^3.19"
},
"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": "231 B"
}
]
}
61 changes: 61 additions & 0 deletions packages/zod/src/contract.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, test, expectTypeOf } from 'vitest';
import { z as zod } from 'zod';

import { zodContract } from './index';

describe('zodContract', () => {
test('string', () => {
const stringContract = zodContract(zod.string());

const smth: unknown = null;

if (stringContract.isData(smth)) {
expectTypeOf(smth).toEqualTypeOf<string>();
expectTypeOf(smth).not.toEqualTypeOf<number>();
}
});

test('complex object', () => {
const complexContract = zodContract(
zod.tuple([
zod.object({
x: zod.number(),
y: zod.literal(false),
k: zod.set(zod.string()),
}),
zod.literal('literal'),
zod.literal(42),
])
);

const smth: unknown = null;

if (complexContract.isData(smth)) {
expectTypeOf(smth).toEqualTypeOf<
[
{
x: number;
y: false;
k: Set<string>;
},
'literal',
42
]
>();

expectTypeOf(smth).not.toEqualTypeOf<number>();

expectTypeOf(smth).not.toEqualTypeOf<
[
{
x: string;
y: false;
k: Set<string>;
},
'literal',
42
]
>();
}
});
});
121 changes: 121 additions & 0 deletions packages/zod/src/contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { z as zod } from 'zod';
import { describe, test, expect } from 'vitest';

import { zodContract } from './index';

describe('zod/zodContract short', () => {
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",
]
`);
});
});
14 changes: 14 additions & 0 deletions packages/zod/src/contract_protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* A _Contract_ is a type that allows to check if a value is conform to a given structure.
*/
export type Contract<Raw, Data extends Raw> = {
/**
* Checks if Raw is Data
*/
isData: (prepared: Raw) => prepared is Data;
/**
* - empty array is dedicated for valid response
* - array of string with validation errors for invalidDataError
*/
getErrorMessages: (prepared: Raw) => string[];
};
29 changes: 29 additions & 0 deletions packages/zod/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { type ZodType } from 'zod';
import { type Contract } from './contract_protocol';

/**
* 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
*/
export function zodContract<D>(data: ZodType<D>): Contract<unknown, D> {
function isData(prepared: unknown): prepared is D {
return data.safeParse(prepared).success;
}

return {
isData,
getErrorMessages(raw) {
const validation = data.safeParse(raw);
if (validation.success) {
return [];
}

return validation.error.errors.map((e) => {
const path = e.path.join('.');
return path !== '' ? `${e.message}, path: ${path}` : e.message;
});
},
};
}
11 changes: 11 additions & 0 deletions packages/zod/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"declaration": true,
"types": ["node"],
"outDir": "dist",
"rootDir": "src",
"baseUrl": "src"
},
"include": ["src/**/*.ts"]
}
Loading

0 comments on commit dcd9d68

Please sign in to comment.