Skip to content

Commit

Permalink
✨ feat: Support asynchronous functions by adding defAsync
Browse files Browse the repository at this point in the history
  • Loading branch information
Snowflyt committed Apr 27, 2024
1 parent b4361f1 commit e4f497c
Show file tree
Hide file tree
Showing 8 changed files with 488 additions and 155 deletions.
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<h1 align="center">Safunc</h1>

<p align="center">
Create <strong><i>runtime-validated</i> functions</strong> with ease, featuring <strong>smart type inference</strong> in TypeScript.
Create <strong><i>runtime-validated</i> functions</strong> for both <strong>synchronous</strong> and <strong>asynchronous</strong> ones with ease, featuring <strong>smart type inference</strong> in TypeScript.
</p>

<p align="center">
Expand All @@ -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)

Expand Down Expand Up @@ -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<Todo[]>>
const res = await fetch("https://jsonplaceholder.typicode.com/todos");
return res.json() as Promise<Todo[]>;
});
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<TodoWrong[]>>
const res = await fetch("https://jsonplaceholder.typicode.com/todos");
return res.json() as Promise<TodoWrong[]>;
});
await getTodosWrong(); // !TypeError: Property '0/id' of the return value of 'function(): Promise<Array<{ userId: integer>0; id: string>0; title: string; completed: boolean }>>' must be a string (was number)

const getTodo = defAsync(
// ^?: Safunc<(id: number) => Promise<Todo>>
sig("integer>0", "=>", todo),
async (id) =>
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`).then(
(res) => res.json() as Promise<Todo>,
),
);
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.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
46 changes: 44 additions & 2 deletions src/safunc.proof.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<Todo[]>;
});
expect(getTodos).to(equal<Safunc<() => Promise<Todo[]>>>);

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<Todo>,
);
// 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<Todo>,
),
),
);
},
);
expect(getTodo).to(
equal<Safunc<((n: number) => Promise<Todo>) & ((n: number, m: number) => Promise<Todo[]>)>>,
);
});

test("Safunc#unwrap", () => {
const add = def(sig("number", "number", "=>", "number"), (n, m) => n + m);
expect(add.unwrap()).to(equal<(n: number, m: number) => number>());
Expand Down
95 changes: 93 additions & 2 deletions src/safunc.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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<Todo[]>;
});
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<TodoWrong[]>;
});
await expect(getTodosWrong()).rejects.toThrowError(
new TypeError(
"Property '0/id' of the return value of 'function(): Promise<Array<{ userId: integer>0; 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<Todo>,
);
// 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<Todo>,
),
),
);
},
);
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): Promise<Array<{ userId: integer>0; 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);
Expand Down
Loading

0 comments on commit e4f497c

Please sign in to comment.