diff --git a/.changeset/five-doors-impress.md b/.changeset/five-doors-impress.md
new file mode 100644
index 00000000..372527ab
--- /dev/null
+++ b/.changeset/five-doors-impress.md
@@ -0,0 +1,5 @@
+---
+'@withease/contracts': minor
+---
+
+Add `nothing` _Contract_ to simplify optional fields handling
diff --git a/.changeset/tidy-ghosts-push.md b/.changeset/tidy-ghosts-push.md
new file mode 100644
index 00000000..01624a7b
--- /dev/null
+++ b/.changeset/tidy-ghosts-push.md
@@ -0,0 +1,5 @@
+---
+'@withease/contracts': minor
+---
+
+Add `anything` _Contract_ to bypass validation
diff --git a/apps/website/docs/contracts/cookbook/optional_fields.md b/apps/website/docs/contracts/cookbook/optional_fields.md
index afafd65a..a9c8adf3 100644
--- a/apps/website/docs/contracts/cookbook/optional_fields.md
+++ b/apps/website/docs/contracts/cookbook/optional_fields.md
@@ -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
@@ -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';
@@ -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.
-:::
diff --git a/apps/website/scripts/jsdoc.mjs b/apps/website/scripts/jsdoc.mjs
index f6fdfb4c..915032f2 100644
--- a/apps/website/scripts/jsdoc.mjs
+++ b/apps/website/scripts/jsdoc.mjs
@@ -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,
});
}
},
@@ -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 ? '' : ''}`
+ `## \`${name}\` ${[
+ tsOnly && '',
+ sinceAll && ``,
+ ]
+ .filter(Boolean)
+ .join('')}`
);
if (overloads.length === 1) {
@@ -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 &&
+ ``,
+ ].join(' ')}`
+ );
content.push(overload.description);
content.push(
...overload.examples.map((example) => '```ts\n' + example + '\n```')
diff --git a/packages/contracts/package.json b/packages/contracts/package.json
index a200ed1f..1d917e66 100644
--- a/packages/contracts/package.json
+++ b/packages/contracts/package.json
@@ -35,7 +35,7 @@
"size-limit": [
{
"path": "./dist/contracts.js",
- "limit": "774 B"
+ "limit": "829 B"
}
]
}
diff --git a/packages/contracts/src/contracts.test.ts b/packages/contracts/src/contracts.test.ts
index 63e5bde4..faa9e5aa 100644
--- a/packages/contracts/src/contracts.test.ts
+++ b/packages/contracts/src/contracts.test.ts
@@ -11,6 +11,8 @@ import {
and,
tuple,
type Contract,
+ nothing,
+ anything,
} from './index';
describe('bool', () => {
@@ -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([]);
+ });
+});
diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts
index e4ec1495..f29b52bd 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -375,6 +375,46 @@ export function tuple(...contracts: Array>): 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 = {
+ isData: (x): x is unknown => true,
+ getErrorMessages: () => [],
+};
+
// -- utils
function createSimpleContract(exepctedType: string): Contract {