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 {