diff --git a/packages/contracts/package.json b/packages/contracts/package.json index dd41ad46..1b788f86 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -32,7 +32,7 @@ "size-limit": [ { "path": "./dist/contracts.js", - "limit": "749 B" + "limit": "797 B" } ] } diff --git a/packages/contracts/src/contracts.test.ts b/packages/contracts/src/contracts.test.ts index 5216223e..3ea1899d 100644 --- a/packages/contracts/src/contracts.test.ts +++ b/packages/contracts/src/contracts.test.ts @@ -1,6 +1,17 @@ import { describe, it, test, expect } from 'vitest'; -import { bool, num, str, obj, or, val, arr, and, Contract } from './index'; +import { + bool, + num, + str, + obj, + or, + val, + arr, + and, + tuple, + Contract, +} from './index'; describe('bool', () => { it('valid', () => { @@ -484,3 +495,55 @@ describe('complex nested', () => { `); }); }); + +describe('tuple', () => { + it('one element', () => { + const cntrct = tuple(str); + + expect(cntrct.isData(['a'])).toBeTruthy(); + expect(cntrct.getErrorMessages(['a'])).toEqual([]); + + expect(cntrct.isData([1])).toBeFalsy(); + expect(cntrct.getErrorMessages([1])).toMatchInlineSnapshot(` + [ + "0: expected string, got number", + ] + `); + }); + + it('two elements', () => { + const cntrct = tuple(str, num); + + expect(cntrct.isData(['a', 1])).toBeTruthy(); + expect(cntrct.getErrorMessages(['a', 1])).toEqual([]); + + expect(cntrct.isData(['a', 'b'])).toBeFalsy(); + expect(cntrct.getErrorMessages(['a', 'b'])).toMatchInlineSnapshot(` + [ + "1: expected number, got string", + ] + `); + + expect(cntrct.isData([1, 'b'])).toBeFalsy(); + expect(cntrct.getErrorMessages([1, 'b'])).toMatchInlineSnapshot(` + [ + "0: expected string, got number", + "1: expected number, got string", + ] + `); + }); + + it('three elements', () => { + const cntrct = tuple(str, num, bool); + + expect(cntrct.isData(['a', 1, true])).toBeTruthy(); + expect(cntrct.getErrorMessages(['a', 1, true])).toEqual([]); + + expect(cntrct.isData(['a', 1, 'b'])).toBeFalsy(); + expect(cntrct.getErrorMessages(['a', 1, 'b'])).toMatchInlineSnapshot(` + [ + "2: expected boolean, got string", + ] + `); + }); +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 7d9d669e..2bd5e479 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -271,6 +271,87 @@ export function arr(c: Contract): Contract { }; } +export function tuple(a: Contract): Contract; +export function tuple( + a: Contract, + b: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract, + d: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract, + d: Contract, + e: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract, + d: Contract, + e: Contract, + f: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract, + d: Contract, + e: Contract, + f: Contract, + g: Contract +): Contract; +export function tuple( + a: Contract, + b: Contract, + c: Contract, + d: Contract, + e: Contract, + f: Contract, + g: Contract, + h: Contract +): Contract; +/** + * Function that creates a _Contract_ that checks if a value is conform to a tuple of the given _Contracts_. + * + * @example + * const userAges = tuple(str, num); + * + * userAges.isData(['Alice', 42]) === true; + * userAges.isData(['Alice', 'what']) === false; + */ +export function tuple(...contracts: Array>): any { + const check = (x: unknown): x is any[] => + Array.isArray(x) && + x.length === contracts.length && + contracts.every((c, i) => c.isData(x[i])); + + return { + isData: check, + getErrorMessages: createGetErrorMessages(check, (x) => { + if (!Array.isArray(x)) { + return [`expected tuple, got ${typeOf(x)}`]; + } + + return x.flatMap((v, idx) => + contracts[idx] + .getErrorMessages(v) + .map((message) => `${idx}: ${message}`) + ); + }), + }; +} + // -- utils function createSimpleContract(