Skip to content

Commit

Permalink
nothing and anything (#87)
Browse files Browse the repository at this point in the history
  • Loading branch information
igorkamyshev authored Aug 1, 2024
1 parent 7bc304b commit 091ba06
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 7 deletions.
5 changes: 5 additions & 0 deletions .changeset/five-doors-impress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@withease/contracts': minor
---

Add `nothing` _Contract_ to simplify optional fields handling
5 changes: 5 additions & 0 deletions .changeset/tidy-ghosts-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@withease/contracts': minor
---

Add `anything` _Contract_ to bypass validation
25 changes: 21 additions & 4 deletions apps/website/docs/contracts/cookbook/optional_fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

By default, all fields mentioned in the schema of `obj` are required. However, you can make a field optional explicitly.

In case you do not care how exactly the field is optional, you can use the `or` in combination with `noting`:

```ts
import { obj, str, num, or, nothing } from '@withease/contracts';

const UserWithOptionalAge = obj({
name: str,
age: or(num, nothing),
});
```

In the example above, the `age` field can be either a number or missing or `null` or `undefined`.

## Only `null`

In case you expect a field to have `null` as a value, you can add it to the field definition as follows:

```ts
Expand All @@ -13,8 +28,14 @@ const UserWithOptionalAge = obj({
});
```

## Only `undefined`

If you expect a field to be missing, you can pass `undefined` as a value:

::: warning
In `@withease/contracts`, `undefined` as a field value is the same as a missing field. If you need to differentiate between the two, you can fallback to more powerful tools like Zod or Runtypes.
:::

```ts
import { obj, str, num, or, val } from '@withease/contracts';

Expand All @@ -23,7 +44,3 @@ const UserWithPossibleNoAge = obj({
age: or(num, val(undefined)),
});
```

::: tip Q: But `undefined` as a field value is not the same as a missing field, right?
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.
:::
20 changes: 18 additions & 2 deletions apps/website/scripts/jsdoc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,15 @@ await Promise.all(

const overloadTag = doc.tags.find((tag) => tag.tag === 'overload');

const sinceTag = doc.tags.find((tag) => tag.tag === 'since');

packageApis.push({
kind,
name,
description: doc.description,
examples,
alias: overloadTag?.name,
since: sinceTag?.name,
});
}
},
Expand All @@ -118,8 +121,15 @@ for (const [packageName, packageApis] of apis) {

for (const [name, overloads] of Object.entries(groupedApis)) {
const tsOnly = overloads.every((api) => api.kind === 'type');
const sinceAll = overloads.every((api) => api.since);

content.push(
`## \`${name}\` ${tsOnly ? '<Badge text="TypeScript only" />' : ''}`
`## \`${name}\` ${[
tsOnly && '<Badge text="TypeScript only" />',
sinceAll && `<Badge text="since ${overloads[0].since}" />`,
]
.filter(Boolean)
.join('')}`
);

if (overloads.length === 1) {
Expand All @@ -131,7 +141,13 @@ for (const [packageName, packageApis] of apis) {
} else {
content.push('Is has multiple overloads 👇');
for (const overload of overloads) {
content.push(`### \`${overload.alias ?? overload.name}\``);
content.push(
`### \`${overload.alias ?? overload.name}\` ${[
!sinceAll &&
overload.since &&
`<Badge text="since ${overload.since}" />`,
].join(' ')}`
);
content.push(overload.description);
content.push(
...overload.examples.map((example) => '```ts\n' + example + '\n```')
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"size-limit": [
{
"path": "./dist/contracts.js",
"limit": "774 B"
"limit": "829 B"
}
]
}
89 changes: 89 additions & 0 deletions packages/contracts/src/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
and,
tuple,
type Contract,
nothing,
anything,
} from './index';

describe('bool', () => {
Expand Down Expand Up @@ -571,3 +573,90 @@ describe('special cases', () => {
expect(contract.isData({ name: 18888, age: 1 })).toBeFalsy();
});
});

describe('nothing', () => {
it('accepts no field', () => {
const cntrct = obj({ key: nothing });

expect(cntrct.isData({})).toBeTruthy();
expect(cntrct.getErrorMessages({})).toEqual([]);

expect(cntrct.isData({ key: 1 })).toBeFalsy();
expect(cntrct.getErrorMessages({ key: 1 })).toMatchInlineSnapshot(`
[
"key: expected null, got 1",
"key: expected undefined, got 1",
]
`);
});

it('accepts null', () => {
const cntrct = obj({ key: nothing });

expect(cntrct.isData({ key: null })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: null })).toEqual([]);
});

it('accepts undefined', () => {
const cntrct = obj({ key: nothing });

expect(cntrct.isData({ key: undefined })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: undefined })).toEqual([]);
});

it('does not break original', () => {
const cntrct = obj({ key: or(num, nothing) });

expect(cntrct.isData({ key: 1 })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: 1 })).toEqual([]);

expect(cntrct.isData({ key: null })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: null })).toEqual([]);

expect(cntrct.isData({ key: undefined })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: undefined })).toEqual([]);

expect(cntrct.isData({ key: 'a' })).toBeFalsy();
expect(cntrct.getErrorMessages({ key: 'a' })).toMatchInlineSnapshot(`
[
"key: expected number, got string",
"key: expected null, got "a"",
"key: expected undefined, got "a"",
]
`);
});
});

describe('anything', () => {
it('accepts any field', () => {
const cntrct = obj({ key: anything });

expect(cntrct.isData({})).toBeTruthy();
expect(cntrct.getErrorMessages({})).toEqual([]);

expect(cntrct.isData({ key: 1 })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: 1 })).toEqual([]);

expect(cntrct.isData({ key: null })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: null })).toEqual([]);

expect(cntrct.isData({ key: undefined })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: undefined })).toEqual([]);
});

it('does not break original', () => {
const cntrct = obj({ key: or(num, anything) });

expect(cntrct.isData({ key: 1 })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: 1 })).toEqual([]);

expect(cntrct.isData({ key: null })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: null })).toEqual([]);

expect(cntrct.isData({ key: undefined })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: undefined })).toEqual([]);

expect(cntrct.isData({ key: 'a' })).toBeTruthy();
expect(cntrct.getErrorMessages({ key: 'a' })).toEqual([]);
});
});
40 changes: 40 additions & 0 deletions packages/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,46 @@ export function tuple(...contracts: Array<Contract<unknown, any>>): any {
};
}

/**
* _Contract_ checking if a value is null or undefined.
* In case of usage as field in `obj` _Contract_, it will allow to omit the field.
*
* @since v1.1.0
*
* @example
*
* const User = obj({
* name: str,
* age: or(num, nothing),
* });
*
* User.isData({ name: 'Alice', age: 42 }) === true;
* User.isData({ name: 'Alice' }) === true;
* User.isData({ name: 'Alice', age: null }) === true;
* User.isData({ name: 'Alice', age: undefined }) === true;
* User.isData({ name: 'Alice', age: 'four two' }) === false;
*/
export const nothing = or(val(null), val(undefined));

/**
* _Contract_ that allows any value, basically a no-op.
*
* @since v1.1.0
*
* @example
*
* anything.isData('hello') === true;
* anything.isData(42) === true;
* anything.isData({}) === true;
* anything.isData([]) === true;
* anything.isData(null) === true;
* anything.isData(undefined) === true;
*/
export const anything: Contract<unknown, unknown> = {
isData: (x): x is unknown => true,
getErrorMessages: () => [],
};

// -- utils

function createSimpleContract<T>(exepctedType: string): Contract<unknown, T> {
Expand Down

0 comments on commit 091ba06

Please sign in to comment.