diff --git a/README.md b/README.md index ab8e33e..d83c48b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

Safunc

-Create runtime-validated functions with ease, featuring smart type inference in TypeScript. +Create runtime-validated functions for both synchronous and asynchronous ones with ease, featuring smart type inference in TypeScript.

@@ -22,7 +22,7 @@ Have a try on [StackBlitz](https://stackblitz.com/edit/safunc-minimal-example?fi ## About -Safunc is a small utility library that allows you to create functions with **runtime validation** of arguments and (optionally) return values, supporting **optional parameters** and **overloaded signatures** with **smart type inference** in TypeScript. It is powered by [Arktype](https://github.com/arktypeio/arktype), an amazing runtime type-checking library using almost 1:1 syntax with TypeScript. +Safunc is a small utility library that allows you to create both **synchronous** and **asynchronous** functions with **runtime validation** of arguments and (optionally) return values, supporting **optional parameters** and **overloaded signatures** with **smart type inference** in TypeScript. It is powered by [Arktype](https://github.com/arktypeio/arktype), an amazing runtime type-checking library using almost 1:1 syntax with TypeScript. ![demo](./demo.gif) @@ -92,6 +92,58 @@ addIntegers(1, 2); // !TypeError: The return value of 'function add(integer, int // Name of the function is used in the error message ``` +### Asynchronous Functions + +When working with asynchronous functions, such as those commonly found in REST API calls, it is likely you want to validate the arguments and return types if the API is unreliable or the data is critical. Safunc facilitates this with the `defAsync` function, which is used in place of `def`: + +```typescript +import { arrayOf, type } from "arktype"; +import { defAsync, sig } from "safunc"; + +type Todo = typeof todo.infer; +const todo = type({ + userId: "integer>0", + id: "integer>0", + title: "string", + completed: "boolean", +}); + +const getTodos = defAsync(sig("=>", arrayOf(todo)), async () => { + // ^?: Safunc<() => Promise> + const res = await fetch("https://jsonplaceholder.typicode.com/todos"); + return res.json() as Promise; +}); +await getTodos(); // => [{ userId: 1, id: 1, title: "delectus aut autem", completed: false }, ...] + +type TodoWrong = typeof todoWrong.infer; +const todoWrong = type({ + userId: "integer>0", + id: "string>0", // <- This will throw a TypeError + title: "string", + completed: "boolean", +}); + +const getTodosWrong = defAsync(sig("=>", arrayOf(todoWrong)), async () => { + // ^?: Safunc<() => Promise> + const res = await fetch("https://jsonplaceholder.typicode.com/todos"); + return res.json() as Promise; +}); +await getTodosWrong(); // !TypeError: Property '0/id' of the return value of 'function(): Promise0; id: string>0; title: string; completed: boolean }>>' must be a string (was number) + +const getTodo = defAsync( + // ^?: Safunc<(id: number) => Promise> + sig("integer>0", "=>", todo), + async (id) => + await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`).then( + (res) => res.json() as Promise, + ), +); +getTodo(0.5); // !TypeError: The 1st argument of 'function(integer>0): Promise<{ userId: integer>0; id: integer>0; title: string; completed: boolean }>' must be an integer (was 0.5) +await getTodo(1); // => { userId: 1, id: 1, title: "delectus aut autem", completed: false } +``` + +`defAsync` supports all features of `def`, including optional parameters and overloaded signatures, which will be discussed later. The only difference is that `defAsync` requires functions to return a `Promise`, and validation of the return value is handled asynchronously (while the arguments are still validated synchronously). + ### Optional Parameters Safunc accommodates optional parameters using the `optional` helper function in its signatures. diff --git a/package.json b/package.json index 74920af..a88c1f8 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "safunc", - "version": "0.1.1", + "version": "0.1.2", "private": true, - "description": "Create runtime-validated functions with ease, supporting optional parameters and overloaded signatures with smart type inference in TypeScript", + "description": "Create runtime-validated functions for both synchronous and asynchronous ones with ease, supporting optional parameters and overloaded signatures with smart type inference in TypeScript", "keywords": [ "typescript", "schema", diff --git a/src/index.ts b/src/index.ts index 487e255..f043495 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export { def, sig, optional } from "./safunc"; +export { def, defAsync, sig, optional } from "./safunc"; export { record, unions, stringifyDefinitionOf } from "./utils/ark"; export type { Safunc, Sig, SigInOut, untyped } from "./safunc"; diff --git a/src/safunc.proof.ts b/src/safunc.proof.ts index f68b87c..270d130 100644 --- a/src/safunc.proof.ts +++ b/src/safunc.proof.ts @@ -1,10 +1,10 @@ /* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { morph } from "arktype"; +import { arrayOf, morph, type } from "arktype"; import { equal, error, expect, it, test } from "typroof"; -import { def, sig } from "./safunc"; +import { def, defAsync, sig } from "./safunc"; import type { Safunc, Sig } from "./safunc"; @@ -196,6 +196,48 @@ it("should support zero-argument functions", () => { ); }); +it("should support asynchronous functions", () => { + type Todo = typeof todo.infer; + const todo = type({ + userId: "integer>0", + id: "integer>0", + title: "string", + completed: "boolean", + }); + + const getTodos = defAsync(sig("=>", arrayOf(todo)), async () => { + // ^? + const res = await fetch("https://jsonplaceholder.typicode.com/todos"); + return res.json() as Promise; + }); + expect(getTodos).to(equal Promise>>); + + const getTodo = defAsync( + // ^? + sig("integer>0", "=>", todo), + sig("integer>0", "integer>0", "=>", arrayOf(todo)), + async (...args) => { + // Return a single todo if only one argument is provided + if (args.length === 1) + return await fetch(`https://jsonplaceholder.typicode.com/todos/${args[0]}`).then( + (res) => res.json() as Promise, + ); + // Return an array of todos in a range of ids if two arguments are provided + const [from, to] = args; + return Promise.all( + Array.from({ length: to - from + 1 }, (_, i) => + fetch(`https://jsonplaceholder.typicode.com/todos/${from + i}`).then( + (res) => res.json() as Promise, + ), + ), + ); + }, + ); + expect(getTodo).to( + equal Promise) & ((n: number, m: number) => Promise)>>, + ); +}); + test("Safunc#unwrap", () => { const add = def(sig("number", "number", "=>", "number"), (n, m) => n + m); expect(add.unwrap()).to(equal<(n: number, m: number) => number>()); diff --git a/src/safunc.spec.ts b/src/safunc.spec.ts index 6cb410c..aaad4dc 100644 --- a/src/safunc.spec.ts +++ b/src/safunc.spec.ts @@ -1,10 +1,10 @@ /* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { morph } from "arktype"; +import { arrayOf, morph, type } from "arktype"; import { expect, it, test } from "vitest"; -import { def, optional, sig } from "./safunc"; +import { def, defAsync, optional, sig } from "./safunc"; import { trimIndent } from "./utils/string"; it("should validate function arguments", () => { @@ -306,6 +306,97 @@ it("should support zero-argument functions", () => { expect(dateString(2024, 4, 26)).toBe("2024-04-26"); }); +it("should support asynchronous functions", async () => { + type Todo = typeof todo.infer; + const todo = type({ + userId: "integer>0", + id: "integer>0", + title: "string", + completed: "boolean", + }); + + const getTodos = defAsync(sig("=>", arrayOf(todo)), async () => { + const res = await fetch("https://jsonplaceholder.typicode.com/todos"); + return res.json() as Promise; + }); + expect((await getTodos()).slice(0, 3)).toEqual([ + { + userId: 1, + id: 1, + title: "delectus aut autem", + completed: false, + }, + { + userId: 1, + id: 2, + title: "quis ut nam facilis et officia qui", + completed: false, + }, + { + userId: 1, + id: 3, + title: "fugiat veniam minus", + completed: false, + }, + ]); + + type TodoWrong = typeof todoWrong.infer; + const todoWrong = type({ + userId: "integer>0", + // Wrong property + id: "string>0", + title: "string", + completed: "boolean", + }); + const getTodosWrong = defAsync(sig("=>", arrayOf(todoWrong)), async () => { + const res = await fetch("https://jsonplaceholder.typicode.com/todos"); + return res.json() as Promise; + }); + await expect(getTodosWrong()).rejects.toThrowError( + new TypeError( + "Property '0/id' of the return value of 'function(): Promise0; id: string>0; title: string; completed: boolean }>>' must be a string (was number)", + ), + ); + + const getTodo = defAsync( + // ^? + sig("integer>0", "=>", todo), + sig("integer>0", "integer>0", "=>", arrayOf(todo)), + async (...args) => { + // Return a single todo if only one argument is provided + if (args.length === 1) + return await fetch(`https://jsonplaceholder.typicode.com/todos/${args[0]}`).then( + (res) => res.json() as Promise, + ); + // Return an array of todos in a range of ids if two arguments are provided + const [from, to] = args; + return Promise.all( + Array.from({ length: to - from + 1 }, (_, i) => + fetch(`https://jsonplaceholder.typicode.com/todos/${from + i}`).then( + (res) => res.json() as Promise, + ), + ), + ); + }, + ); + expect(() => getTodo(0)).toThrowError( + new TypeError( + "The 1st argument of 'function(integer>0): Promise<{ userId: integer>0; id: integer>0; title: string; completed: boolean }>' (overload 1 of 2) must be more than 0 (was 0)", + ), + ); + expect(() => getTodo(1, 2.5)).toThrowError( + new TypeError( + "The 2nd argument of 'function(integer>0, integer>0): Promise0; id: integer>0; title: string; completed: boolean }>>' (overload 2 of 2) must be an integer (was 2.5)", + ), + ); + await expect(getTodo(1)).resolves.toEqual({ + userId: 1, + id: 1, + title: "delectus aut autem", + completed: false, + }); +}); + test("Safunc#unwrap", () => { const add = def(sig("number", "number", "=>", "number"), (n, m) => n + m); expect(add.unwrap()(1, 2)).toBe(3); diff --git a/src/safunc.ts b/src/safunc.ts index 0b23013..67e24bf 100644 --- a/src/safunc.ts +++ b/src/safunc.ts @@ -92,7 +92,7 @@ export interface SigInOut { $availableArgumentLengths: readonly number[]; - toString: () => string; + toString: (opts?: { wrapReturnTypeWithPromise?: boolean }) => string; } type AsOutAll = @@ -252,7 +252,7 @@ export const sig: SigBuilder = (...args: unknown[]) => { $availableArgumentLengths, - toString: () => { + toString: ({ wrapReturnTypeWithPromise = false } = {}) => { let res = "("; res += $parameterSchemas .map((s) => @@ -260,7 +260,8 @@ export const sig: SigBuilder = (...args: unknown[]) => { ) .join(", "); res += ")"; - if (!isUntyped($returnSchema)) res += `: ${stringifyDefinitionOf($returnSchema)}`; + if (!isUntyped($returnSchema)) + res += `: ${wrapReturnTypeWithPromise ? "Promise<" + stringifyDefinitionOf($returnSchema) + ">" : stringifyDefinitionOf($returnSchema)}`; return res; }, } satisfies Sig; @@ -309,6 +310,159 @@ export interface Safunc extends F { allowArguments: (...args: unknown[]) => boolean; } +const _defBuilder = + ({ async }: { async: boolean }) => + (...args: unknown[]) => { + const sigs = args.slice(0, -1) as Sig[]; + const fn = args[args.length - 1] as Fn; + + let $matchedMorphedArguments: unknown[] = []; + const matchArguments = (...args: unknown[]): Sig => { + const availableArgumentLengths = [ + ...new Set([...sigs.flatMap((sig) => sig.$availableArgumentLengths)]), + ].sort(); + if (!availableArgumentLengths.includes(args.length)) { + const message = `Expected ${humanizeNaturalNumbers(availableArgumentLengths)} arguments, but got ${args.length}`; + throw new TypeError(message); + } + + const sigAndMessages: [Sig, string][] = []; + for (let overloadIdx = 0; overloadIdx < sigs.length; overloadIdx++) { + const sig = sigs[overloadIdx]!; + const { $availableArgumentLengths, $parameterSchemas } = sig; + if (!$availableArgumentLengths.includes(args.length)) { + sigAndMessages.push([sig, "ARG_LENGTH_NOT_MATCH"]); + continue; + } + const morphedArgs: unknown[] = []; + for (let i = 0; i < args.length; i++) { + let validator = $parameterSchemas[i]; + if (!validator) continue; + if (isOptional(validator)) validator = validator[optionalSymbol]; + const { data, problems } = validator(args[i]); + if (!problems) { + morphedArgs.push(data); + continue; + } + const problem = problems[0]!; + const reason = problem.reason; + let message = ""; + // If the message is not just the reason + if (problem.message.length !== reason.length) { + let prefix = problem.message.toLowerCase().slice(0, -reason.length).trim(); + // If it is likely a property name (contains no space and is not a number) + if (!prefix.includes(" ") && isNaN(Number(prefix))) prefix = `Property '${prefix}'`; + message += prefix + " of "; + } + message += `the ${ordinal(i + 1)} argument of 'function`; + // If function has a name + if (fn.name) message += ` ${fn.name}`; + message += sig.toString({ wrapReturnTypeWithPromise: async }) + "' "; + if (sigs.length > 1) message += `(overload ${overloadIdx + 1} of ${sigs.length}) `; + message += reason; + message = capitalize(message); + sigAndMessages.push([sig, message]); + break; + } + if (!sigAndMessages[overloadIdx]) { + $matchedMorphedArguments = morphedArgs; + return sig; + } + } + + const errors = sigAndMessages + .map(([sig, message], i) => ({ i, sig, message })) + .filter(({ message: m }) => m !== "ARG_LENGTH_NOT_MATCH"); + + if (errors.length === 1) throw new TypeError(errors[0]!.message); + + let message = "No overload "; + if (fn.name) message += `of function '${fn.name}' `; + message += "matches this call.\n"; + for (const { i, message: m, sig } of errors) { + message += ` Overload ${i + 1} of ${sigs.length}, '${sig.toString({ wrapReturnTypeWithPromise: async })}', gave the following error.\n`; + message += + " " + + m.replace(/argument of 'function.+?'( \(overload \d+ of \d+\))?/g, "argument") + + "\n"; + } + message = message.trimEnd(); + throw new TypeError(message); + }; + + const assertReturn = (sig: Sig, r: unknown) => { + const { data, problems } = sig.$returnSchema(r); + if (!problems) return data; + const problem = problems[0]!; + const reason = problem.reason; + let message = ""; + // If the message is not just the reason + if (problem.message.length !== reason.length) { + let prefix = problem.message.toLowerCase().slice(0, -reason.length).trim(); + // If it is likely a property name (contains no space and is not a number) + if (!prefix.includes(" ") && isNaN(Number(prefix))) prefix = `Property '${prefix}'`; + message += prefix + " of "; + } + message += "the return value of 'function"; + // If function has a name + if (fn.name) message += ` ${fn.name}`; + message += sig.toString({ wrapReturnTypeWithPromise: async }) + "' "; + if (sigs.length > 1) message += `(overload ${sigs.indexOf(sig) + 1} of ${sigs.length}) `; + message += reason; + message = capitalize(message); + throw new TypeError(message); + }; + + const f = (...args: never[]) => { + const matchedSig = matchArguments(...args); + if (!async) return assertReturn(matchedSig, fn(...($matchedMorphedArguments as never[]))); + return new Promise((resolve, reject) => { + void (fn(...($matchedMorphedArguments as never[])) as Promise).then((res) => { + try { + resolve(assertReturn(matchedSig, res)); + } catch (e) { + reject(e); + } + }); + }); + }; + + // Keep the name of the function for better error messages + Object.defineProperty(f, "name", { value: fn.name }); + + const res = f.bind(null); + Object.defineProperty(res, "name", { value: fn.name }); + + Object.assign(res, { + $sigs: sigs, + $fn: fn, + + unwrap: () => f, + + matchArguments: (...args: unknown[]) => { + try { + return matchArguments(...args); + } catch (e) { + return null; + } + }, + assertArguments: (...args: unknown[]) => { + matchArguments(...args); + }, + + allowArguments: (...args: unknown[]) => { + try { + matchArguments(...args); + return true; + } catch { + return false; + } + }, + } satisfies Safunc); + + return res; + }; + /** * Create a type-safe function with runtime parameters and (optionally) return type validation. * @returns @@ -383,144 +537,37 @@ export const def: { | Parameters | Parameters | Parameters | Parameters | Parameters | Parameters) => FInReturn<[FIn1, FIn2, FIn3, FIn4, FIn5, FIn6, FIn7]>>(sig1: SigInOut, sig2: SigInOut, sig3: SigInOut, sig4: SigInOut, sig5: SigInOut, sig6: SigInOut, sig7: SigInOut, fn: F): Safunc> & RefineUntyped> & RefineUntyped> & RefineUntyped> & RefineUntyped> & RefineUntyped> & RefineUntyped>>; // prettier-ignore | Parameters | Parameters | Parameters | Parameters | Parameters | Parameters | Parameters) => FInReturn<[FIn1, FIn2, FIn3, FIn4, FIn5, FIn6, FIn7, FIn8]>>(sig1: SigInOut, sig2: SigInOut, sig3: SigInOut, sig4: SigInOut, sig5: SigInOut, sig6: SigInOut, sig7: SigInOut, sig8: SigInOut, fn: F): Safunc> & RefineUntyped> & RefineUntyped> & RefineUntyped> & RefineUntyped> & RefineUntyped> & RefineUntyped> & RefineUntyped>>; -} = ((...args: unknown[]) => { - const sigs = args.slice(0, -1) as Sig[]; - const fn = args[args.length - 1] as Fn; - - let $matchedMorphedArguments: unknown[] = []; - const matchArguments = (...args: unknown[]): Sig => { - const availableArgumentLengths = [ - ...new Set([...sigs.flatMap((sig) => sig.$availableArgumentLengths)]), - ].sort(); - if (!availableArgumentLengths.includes(args.length)) { - const message = `Expected ${humanizeNaturalNumbers(availableArgumentLengths)} arguments, but got ${args.length}`; - throw new TypeError(message); - } - - const sigAndMessages: [Sig, string][] = []; - for (let overloadIdx = 0; overloadIdx < sigs.length; overloadIdx++) { - const sig = sigs[overloadIdx]!; - const { $availableArgumentLengths, $parameterSchemas } = sig; - if (!$availableArgumentLengths.includes(args.length)) { - sigAndMessages.push([sig, "ARG_LENGTH_NOT_MATCH"]); - continue; - } - const morphedArgs: unknown[] = []; - for (let i = 0; i < args.length; i++) { - let validator = $parameterSchemas[i]; - if (!validator) continue; - if (isOptional(validator)) validator = validator[optionalSymbol]; - const { data, problems } = validator(args[i]); - if (!problems) { - morphedArgs.push(data); - continue; - } - const problem = problems[0]!; - const reason = problem.reason; - let message = ""; - // If the message is not just the reason - if (problem.message.length !== reason.length) { - let prefix = problem.message.toLowerCase().slice(0, -reason.length).trim(); - // If it is likely a property name (contains no space and is not a number) - if (!prefix.includes(" ") && isNaN(Number(prefix))) prefix = `Property '${prefix}'`; - message += prefix + " of "; - } - message += `the ${ordinal(i + 1)} argument of 'function`; - // If function has a name - if (fn.name) message += ` ${fn.name}`; - message += sig.toString() + "' "; - if (sigs.length > 1) message += `(overload ${overloadIdx + 1} of ${sigs.length}) `; - message += reason; - message = capitalize(message); - sigAndMessages.push([sig, message]); - break; - } - if (!sigAndMessages[overloadIdx]) { - $matchedMorphedArguments = morphedArgs; - return sig; - } - } - - const errors = sigAndMessages - .map(([sig, message], i) => ({ i, sig, message })) - .filter(({ message: m }) => m !== "ARG_LENGTH_NOT_MATCH"); - - if (errors.length === 1) throw new TypeError(errors[0]!.message); - - let message = "No overload "; - if (fn.name) message += `of function '${fn.name}' `; - message += "matches this call.\n"; - for (const { i, message: m, sig } of errors) { - message += ` Overload ${i + 1} of ${sigs.length}, '${sig.toString()}', gave the following error.\n`; - message += - " " + - m.replace(/argument of 'function.+?'( \(overload \d+ of \d+\))?/g, "argument") + - "\n"; - } - message = message.trimEnd(); - throw new TypeError(message); - }; +} = _defBuilder({ async: false }) as never; - const assertReturn = (sig: Sig, r: unknown) => { - const { data, problems } = sig.$returnSchema(r); - if (!problems) return data; - const problem = problems[0]!; - const reason = problem.reason; - let message = ""; - // If the message is not just the reason - if (problem.message.length !== reason.length) { - let prefix = problem.message.toLowerCase().slice(0, -reason.length).trim(); - // If it is likely a property name (contains no space and is not a number) - if (!prefix.includes(" ") && isNaN(Number(prefix))) prefix = `Property '${prefix}'`; - message += prefix + " of "; - } - message += "the return value of 'function"; - // If function has a name - if (fn.name) message += ` ${fn.name}`; - message += sig.toString() + "' "; - if (sigs.length > 1) message += `(overload ${sigs.indexOf(sig) + 1} of ${sigs.length}) `; - message += reason; - message = capitalize(message); - throw new TypeError(message); - }; - - const f = (...args: never[]) => { - const matchedSig = matchArguments(...args); - return assertReturn(matchedSig, fn(...($matchedMorphedArguments as never[]))); - }; +type Asyncify = + F extends infer F extends Fn ? (...args: Parameters) => Promise> : never; - // Keep the name of the function for better error messages - Object.defineProperty(f, "name", { value: fn.name }); - - const res = f.bind(null); - Object.defineProperty(res, "name", { value: fn.name }); - - Object.assign(res, { - $sigs: sigs, - $fn: fn, - - unwrap: () => f, - - matchArguments: (...args: unknown[]) => { - try { - return matchArguments(...args); - } catch (e) { - return null; - } - }, - assertArguments: (...args: unknown[]) => { - matchArguments(...args); - }, - - allowArguments: (...args: unknown[]) => { - try { - matchArguments(...args); - return true; - } catch { - return false; - } - }, - } satisfies Safunc); - - return res; -}) as never; +/** + * Create a type-safe asynchronous function with runtime parameters and (optionally) return type validation. + * + * Same as {@link def}, but the return type of the function must be a `Promise`. + */ +export const defAsync: { + >>( + sig: SigInOut, + fn: F, + ): Safunc< + ( + ...args: LabeledBy, Parameters> + ) => Promise, ReturnType>> + >; + // prettier-ignore + | Parameters) => Promise>>(sig1: SigInOut, sig2: SigInOut, fn: F): Safunc>> & Asyncify>>>; + // prettier-ignore + | Parameters | Parameters) => Promise>>(sig1: SigInOut, sig2: SigInOut, sig3: SigInOut, fn: F): Safunc>> & Asyncify>> & Asyncify>>>; + // prettier-ignore + | Parameters | Parameters | Parameters) => Promise>>(sig1: SigInOut, sig2: SigInOut, sig3: SigInOut, sig4: SigInOut, fn: F): Safunc>> & Asyncify>> & Asyncify>> & Asyncify>>>; + // prettier-ignore + | Parameters | Parameters | Parameters | Parameters) => Promise>>(sig1: SigInOut, sig2: SigInOut, sig3: SigInOut, sig4: SigInOut, sig5: SigInOut, fn: F): Safunc>> & Asyncify>> & Asyncify>> & Asyncify>> & Asyncify>>>; + // prettier-ignore + | Parameters | Parameters | Parameters | Parameters | Parameters) => Promise>>(sig1: SigInOut, sig2: SigInOut, sig3: SigInOut, sig4: SigInOut, sig5: SigInOut, sig6: SigInOut, fn: F): Safunc>> & Asyncify>> & Asyncify>> & Asyncify>> & Asyncify>> & Asyncify>>>; + // prettier-ignore + | Parameters | Parameters | Parameters | Parameters | Parameters | Parameters) => Promise>>(sig1: SigInOut, sig2: SigInOut, sig3: SigInOut, sig4: SigInOut, sig5: SigInOut, sig6: SigInOut, sig7: SigInOut, fn: F): Safunc>> & Asyncify>> & Asyncify>> & Asyncify>> & Asyncify>> & Asyncify>> & Asyncify>>>; + // prettier-ignore + | Parameters | Parameters | Parameters | Parameters | Parameters | Parameters | Parameters) => Promise>>(sig1: SigInOut, sig2: SigInOut, sig3: SigInOut, sig4: SigInOut, sig5: SigInOut, sig6: SigInOut, sig7: SigInOut, sig8: SigInOut, fn: F): Safunc>> & Asyncify>> & Asyncify>> & Asyncify>> & Asyncify>> & Asyncify>> & Asyncify>> & Asyncify>>>; +} = _defBuilder({ async: true }) as never; diff --git a/test/README.proof.ts b/test/README.proof.ts index 8d3f666..677ab9c 100644 --- a/test/README.proof.ts +++ b/test/README.proof.ts @@ -1,7 +1,7 @@ -import { morph } from "arktype"; +import { arrayOf, morph, type } from "arktype"; import { equal, expect, test } from "typroof"; -import { def, optional, sig } from "../src"; +import { def, defAsync, optional, sig } from "../src"; import type { Safunc, Sig } from "../src"; @@ -28,6 +28,48 @@ test("introduction", () => { expect(addIntegers).to(equal number>>); }); +test("asynchronous functions", () => { + type Todo = typeof todo.infer; + const todo = type({ + userId: "integer>0", + id: "integer>0", + title: "string", + completed: "boolean", + }); + + const getTodos = defAsync(sig("=>", arrayOf(todo)), async () => { + // ^? + const res = await fetch("https://jsonplaceholder.typicode.com/todos"); + return res.json() as Promise; + }); + expect(getTodos).to(equal Promise>>); + + type TodoWrong = typeof todoWrong.infer; + const todoWrong = type({ + userId: "integer>0", + id: "string>0", + title: "string", + completed: "boolean", + }); + + const getTodosWrong = defAsync(sig("=>", arrayOf(todoWrong)), async () => { + // ^? + const res = await fetch("https://jsonplaceholder.typicode.com/todos"); + return res.json() as Promise; + }); + expect(getTodosWrong).to(equal Promise>>); + + const getTodo = defAsync( + // ^? + sig("integer>0", "=>", todo), + async (id) => + await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`).then( + (res) => res.json() as Promise, + ), + ); + expect(getTodo).to(equal Promise>>); +}); + test("optional parameters", () => { const range1 = def( // ^? diff --git a/test/README.spec.ts b/test/README.spec.ts index c0f05a1..d331eea 100644 --- a/test/README.spec.ts +++ b/test/README.spec.ts @@ -1,10 +1,10 @@ /* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { morph } from "arktype"; +import { arrayOf, morph, type } from "arktype"; import { expect, test } from "vitest"; -import { def, optional, sig } from "../src"; +import { def, defAsync, optional, sig } from "../src"; import { trimIndent } from "@/utils/string"; @@ -48,6 +48,65 @@ test("introduction", () => { ); }); +test("asynchronous functions", async () => { + type Todo = typeof todo.infer; + const todo = type({ + userId: "integer>0", + id: "integer>0", + title: "string", + completed: "boolean", + }); + + const getTodos = defAsync(sig("=>", arrayOf(todo)), async () => { + // ^?: Safunc<() => Promise> + const res = await fetch("https://jsonplaceholder.typicode.com/todos"); + return res.json() as Promise; + }); + await getTodos(); // => [, ...] + expect((await getTodos()).slice(0, 1)).toEqual([ + { userId: 1, id: 1, title: "delectus aut autem", completed: false }, + ]); + + type TodoWrong = typeof todoWrong.infer; + const todoWrong = type({ + userId: "integer>0", + id: "string>0", // <- This will throw a TypeError + title: "string", + completed: "boolean", + }); + + const getTodosWrong = defAsync(sig("=>", arrayOf(todoWrong)), async () => { + // ^?: Safunc<() => Promise> + const res = await fetch("https://jsonplaceholder.typicode.com/todos"); + return res.json() as Promise; + }); + await expect(getTodosWrong()).rejects.toThrowError( + new TypeError( + "Property '0/id' of the return value of 'function(): Promise0; id: string>0; title: string; completed: boolean }>>' must be a string (was number)", + ), + ); + + const getTodo = defAsync( + // ^?: Safunc<(id: number) => Promise> + sig("integer>0", "=>", todo), + async (id) => + await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`).then( + (res) => res.json() as Promise, + ), + ); + expect(() => void getTodo(0.5)).toThrowError( + new TypeError( + "The 1st argument of 'function(integer>0): Promise<{ userId: integer>0; id: integer>0; title: string; completed: boolean }>' must be an integer (was 0.5)", + ), + ); + await expect(getTodo(1)).resolves.toEqual({ + userId: 1, + id: 1, + title: "delectus aut autem", + completed: false, + }); +}); + test("optional parameters", () => { const range_ = def( sig("integer", optional("integer"), optional("integer>0"), "=>", "integer[]"),