Skip to content

nothing and anything #87

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading