Skip to content

Commit 16968b8

Browse files
authored
@withease/contracts (#85)
1 parent 52fcac8 commit 16968b8

30 files changed

+1799
-10
lines changed

.changeset/spicy-owls-bake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@withease/contracts': major
3+
---
4+
5+
Initial release

.prettierignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22

33
/dist
44
/coverage
5-
pnpm-lock.yaml
5+
pnpm-lock.yaml
6+
api.md

apps/website/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
docs/.vitepress/cache
2-
CHANGELOG.md
2+
CHANGELOG.md
3+
api.md

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export default defineConfig({
4848
{ text: 'redux', link: '/redux/' },
4949
{ text: 'web-api', link: '/web-api/' },
5050
{ text: 'factories', link: '/factories/' },
51+
{ text: 'contracts', link: '/contracts/' },
5152
],
5253
},
5354
{ text: 'Magazine', link: '/magazine/' },
@@ -114,6 +115,27 @@ export default defineConfig({
114115
{ text: 'Motivation', link: '/factories/motivation' },
115116
{ text: 'Important Caveats', link: '/factories/important_caveats' },
116117
]),
118+
...createSidebar('contracts', [
119+
{ text: 'Get Started', link: '/contracts/' },
120+
{
121+
text: 'Cookbook',
122+
items: [
123+
{
124+
text: 'Optional Fields',
125+
link: '/contracts/cookbook/optional_fields',
126+
},
127+
{
128+
text: 'Custom Matchers',
129+
link: '/contracts/cookbook/custom_matchers',
130+
},
131+
{
132+
text: 'Merge Objects',
133+
link: '/contracts/cookbook/merge_objects',
134+
},
135+
],
136+
},
137+
{ text: 'APIs', link: '/contracts/api' },
138+
]),
117139
'/magazine/': [
118140
{
119141
text: 'Architecture',

apps/website/docs/.vitepress/theme/LiveDemo.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Sandpack } from 'sandpack-vue3';
33
44
import repositoryPackageJson from '../../../../../package.json';
55
import webApiRaw from '../../../../../packages/web-api/dist/web-api.js?raw';
6+
import contractsRaw from '../../../../../packages/contracts/dist/contracts.js?raw';
67
78
const repositoryVersions = {
89
...repositoryPackageJson.dependencies,
@@ -14,6 +15,7 @@ const props = defineProps(['demoFile']);
1415
const files = {
1516
'/src/App.vue': props.demoFile,
1617
...localPackage({ name: 'web-api', content: webApiRaw }),
18+
...localPackage({ name: 'contracts', content: contractsRaw }),
1719
};
1820
1921
const customSetup = {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<script setup>
2+
import { arr, num } from '@withease/contracts';
3+
4+
const contract = arr(num);
5+
</script>
6+
7+
<template>
8+
<h1>
9+
Out <em>Contract</em> is ensuring that passed data is an array of numbers
10+
</h1>
11+
<section>
12+
<h2>Valid data example</h2>
13+
<p>Let us pass [1, 2, 3] to the <em>Contract</em></p>
14+
<p>isData() 👉 {{ contract.isData([1, 2, 3]) }}</p>
15+
<p>
16+
getErrorMessages() 👉
17+
{{ JSON.stringify(contract.getErrorMessages([1, 2, 3])) }}
18+
</p>
19+
</section>
20+
21+
<section>
22+
<h2>Invalid data example</h2>
23+
<p>
24+
Let us pass [1, 'WHOA', 3] to the <em>Contract</em>. instead of number.
25+
</p>
26+
<p>isData() 👉 {{ contract.isData([1, 'WHOA', 3]) }}</p>
27+
<p>
28+
getErrorMessages() 👉
29+
{{ JSON.stringify(contract.getErrorMessages([1, 'WHOA', 3])) }}
30+
</p>
31+
</section>
32+
</template>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Custom Matchers
2+
3+
Since `@withease/contracts` is built on top of [_Contract_](/protocols/contract), you can embed your own matcher into the schema naturally.
4+
5+
Let us write a custom matcher that checks if an age of a user is within a certain range:
6+
7+
```ts
8+
import { type Contract, and, num } from '@withease/contracts';
9+
10+
function age({ min, max }: { min: number; max: number }) {
11+
return and(num, {
12+
isData: (data) => data >= min && data <= max,
13+
getErrorMessages: (data) => [
14+
`Expected a number between ${min} and ${max}, but got ${data}`,
15+
],
16+
});
17+
}
18+
```
19+
20+
Now you can use this matcher in your schema:
21+
22+
```ts
23+
import { obj, str } from '@withease/contracts';
24+
25+
const User = obj({
26+
name: str,
27+
age: age(18, 100),
28+
});
29+
```
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Merge Objects
2+
3+
Merge two [_Contracts_](/protocols/contract) representing objects into a single [_Contract_](/protocols/contract) representing an object with fields from both input objects is a common operation in many applications.
4+
5+
With `@withease/contracts` in can be done with simple `and` call:
6+
7+
```ts
8+
import { num, str, obj, and, type UnContract } from '@withease/contracts';
9+
10+
const Price = obj({
11+
currency: str,
12+
value: num,
13+
});
14+
15+
const PriceWithDiscount = and(
16+
Price,
17+
obj({
18+
discount: num,
19+
})
20+
);
21+
22+
type TPriceWithDiscount = UnContract<typeof PriceWithDiscount>;
23+
// 👆 { currency: string, value: number, discount: number }
24+
```
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Optional Fields
2+
3+
By default, all fields mentioned in the schema of `obj` are required. However, you can make a field optional explicitly.
4+
5+
In case you expect a field to have `null` as a value, you can add it to the field definition as follows:
6+
7+
```ts
8+
import { obj, str, num, or, val } from '@withease/contracts';
9+
10+
const UserWithOptionalAge = obj({
11+
name: str,
12+
age: or(num, val(null)),
13+
});
14+
```
15+
16+
If you expect a field to be missing, you can pass `undefined` as a value:
17+
18+
```ts
19+
import { obj, str, num, or, val } from '@withease/contracts';
20+
21+
const UserWithPossibleNoAge = obj({
22+
name: str,
23+
age: or(num, val(undefined)),
24+
});
25+
```
26+
27+
::: tip Q: But `undefined` as a field value is not the same as a missing field, right?
28+
A: Correct. However, in **most cases**, you can treat `undefined` as a missing field and vice versa. In case you _really_ need to differentiate between the two, you can fallback to more powerful tools like Zod or Runtypes, `@withease/contracts` aims to cover only the most common use cases.
29+
:::

apps/website/docs/contracts/index.md

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
<script setup>
2+
import pkg from '../../../../packages/contracts/package.json';
3+
import demoFile from './array_numbers.live.vue?raw';
4+
import { data as sizes } from './sizes.data';
5+
import SizeChart from './size_chart.vue';
6+
import bytes from 'bytes'
7+
8+
const maxSize = pkg['size-limit'].at(0).limit;
9+
10+
const allSizes = [
11+
{ name: '@withease/contracts', size: bytes(maxSize) },
12+
...(sizes ?? [])
13+
];
14+
</script>
15+
16+
# contracts
17+
18+
Extremely small library (less than **{{maxSize}}** controlled by CI) for creating [_Contracts_](/protocols/contract) that allows you to introduce data validation on edges of the application with no performance compromises.
19+
20+
## Installation
21+
22+
First, you need to install package:
23+
24+
::: code-group
25+
26+
```sh [pnpm]
27+
pnpm install @withease/contracts
28+
```
29+
30+
```sh [yarn]
31+
yarn add @withease/contracts
32+
```
33+
34+
```sh [npm]
35+
npm install @withease/contracts
36+
```
37+
38+
:::
39+
40+
## Creating a _Contract_
41+
42+
`@withease/contracts` exports bunch of utilities that can be used to create a _Contract_, read the full API reference [here](/contracts/api). Any of the utilities returns a _Contract_ object, that accepts something `unknown` and checks if it is something concrete defined by the used utility.
43+
44+
<LiveDemo :demoFile="demoFile" />
45+
46+
## Extracting types from a _Contract_
47+
48+
`@withease/contracts` provides a special type `UnContract` that can be used to extract a type from a _Contract_.
49+
50+
```ts
51+
import { type UnContract, obj, str, num } from '@withease/contracts';
52+
53+
const UserContract = obj({
54+
id: num,
55+
name: str,
56+
email: str,
57+
});
58+
59+
// type User = { id: number, name: string, email: string }
60+
type User = UnContract<typeof UserContract>;
61+
```
62+
63+
## Usage of a _Contract_
64+
65+
`@withease/contracts` is designed to be compatible with Effector's ecosystem without additional interop, so most of the time you can pass created [_Contract_](/protocols/contract) to other Effector's libraries as is.
66+
67+
### Farfetched
68+
69+
[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).
70+
71+
```ts
72+
import { createJsonQuery } from '@farfetched/core';
73+
import { obj, str, arr, val, or } from '@withease/contracts';
74+
75+
const characterQuery = createJsonQuery({
76+
params: declareParams<{ id: number }>(),
77+
request: {
78+
method: 'GET',
79+
url: ({ id }) => `https://rickandmortyapi.com/api/character/${id}`,
80+
},
81+
response: {
82+
// after receiving data from the server
83+
// check if it is conforms the Contract to ensure
84+
// API does not return something unexpected
85+
contract: obj({
86+
id: str,
87+
name: str,
88+
status: Status,
89+
species: str,
90+
type: str,
91+
gender: Gender,
92+
origin: obj({ name: str, url: str }),
93+
location: obj({ name: str, url: str }),
94+
image: or(val('Female'), val('Male'), val('Genderless')),
95+
episode: arr(str),
96+
}),
97+
},
98+
});
99+
```
100+
101+
### effector-storage
102+
103+
[`effector-storage`](https://github.com/yumauri/effector-storage) is a small module for Effector to sync stores with different storages (local storage, session storage, async storage, IndexedDB, cookies, server side storage, etc).
104+
105+
Since data is stored in an external storage it is important to validate it before using it in the application.
106+
107+
```ts
108+
import { createStore } from 'effector';
109+
import { persist } from 'effector-storage';
110+
import { num } from '@withease/contracts';
111+
112+
const $counter = createStore(0);
113+
114+
persist({
115+
store: $counter,
116+
key: 'counter',
117+
// after reading value from a storage check if a value is number
118+
// to avoid pushing invalid data to the Store
119+
contract: num,
120+
});
121+
```
122+
123+
## Integration with other libraries
124+
125+
Since `@withease/contracts` is compatible [_Contract_](/protocols/contract) protocol it can be used with any library that supports it.
126+
127+
For instance, you can define a part of a [_Contract_](/protocols/contract) with [Zod](https://zod.dev/) and combine it with `@withease/contracts`:
128+
129+
```ts
130+
import { z } from 'zod';
131+
import { arr, obj } from '@withease/contracts';
132+
import { zodContract } from '@farfetched/zod';
133+
134+
const User = z.object({
135+
name: z.string(),
136+
});
137+
138+
const MyContract = arr(
139+
obj({
140+
// 👇 easily integrate Zod via compatibility layer
141+
users: zodContract(User),
142+
})
143+
);
144+
```
145+
146+
The full list of libraries that support _Contract_ protocol can be found [here](/protocols/contract).
147+
148+
## Differences from other libraries
149+
150+
<section v-if="sizes">
151+
It is extremely small and we mean it 👇
152+
153+
<br />
154+
<br />
155+
156+
<SizeChart :sizes="allSizes" />
157+
158+
::: tip
159+
Data fetched directly from https://esm.run/ and updates on every commit.
160+
:::
161+
162+
</section>
163+
<section v-else>
164+
It is significantly smaller than other libraries for creating _Contracts_.
165+
</section>
166+
167+
Of course smaller size is comes with some trade-offs, but we believe that in most cases it is worth it. `@withease/contracts` covers most of the common cases but does not try to be a silver bullet for all possible cases. It does not aim to have the following features from other libraries:
168+
169+
- Branded types ([like in Runtypes](https://github.com/runtypes/runtypes?tab=readme-ov-file#branded-types))
170+
- Advanced string-validators ([like IP-validation in Zod](https://zod.dev/?id=ip-addresses))
171+
- Promise schemas ([like in Zod](https://zod.dev/?id=promise))
172+
- Error i18n ([like in Valibot](https://valibot.dev/guides/internationalization/))
173+
- ...and many other features that are not needed in _most_ of the cases
174+
175+
::: tip Q: What if I started a project with `@withease/contracts` and then realized that I need some of the features that are not covered by it?
176+
A: No worries! You can easily integrate `@withease/contracts` with other libraries that have the features you need. Check out the [Integration with other libraries](#integration-with-other-libraries) section for more details.
177+
:::

0 commit comments

Comments
 (0)