Safunc
Create runtime-validated functions with ease, featuring smart type inference in TypeScript.

Have a try on TS Playground.
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, an amazing runtime type-checking library using almost 1:1 syntax with TypeScript.

Installation
npm install safunc
... or any other package manager you prefer!
Usage
The following shows a minimal example of how to use Safunc to create a type-safe add
function with runtime validation that only accepts two numbers and returns a number.
import { def, sig } from "safunc";
const add = def(sig("number", "number", "=>", "number"), (n, m) => n + m);
add(1, 2);
add(1);
add("foo", 2);
const add = def(sig("number", "number"), (n, m) => n + m);
add(1, "foo");
You can also define functions with optional parameters and overloaded signatures, which will be detailed later.
import { def, sig, optional } from "safunc";
const repeat = def(
sig("string", optional({ "n?": "integer>0" }), "=>", "string"),
(s, { n = 2 } = {}) => s.repeat(n),
);
const range = def(
sig("integer", "=>", "integer[]"),
sig("integer", "integer", "?integer>0", "=>", "integer[]"),
function range(startOrStop, stop?, step = 1) {
},
);
As you can see here, you can use def(...signatures, fn)
to create a function with runtime validation. Each signature is composed of several valid Arktype type definitions, split by =>
to separate parameters and return type. The return type along with =>
can be omitted if you don't want to validate the return value.
The types of parameters n
and m
in the above add
example are automatically inferred as number
from its signature, eliminating the need to specify parameter types within the function body. The same is true for repeatString
and range
in the examples above.
The sig
function supports 0-4 parameters and 1 optional return type, which should be enough for most cases. If you find it not enough, you’d better consider redesigning your function.
You can use a function expression with name instead of anonymous functions for better error messages:
const addIntegers = def(sig("integer", "integer", "=>", "integer"), function add(n, m) {
return n + m + 0.5;
});
addIntegers(1, 2);
Optional Parameters
Safunc accommodates optional parameters using the optional
helper function in its signatures.
import { def, sig, optional } from "safunc";
const range = def(
sig("integer", optional("integer"), optional("integer>0"), "=>", "integer[]"),
function range(startOrStop, stop, step = 1) {
const start = stop === undefined ? 0 : startOrStop;
stop ??= startOrStop;
return Array.from({ length: Math.ceil((stop - start) / step) }, (_, i) => start + i * step);
},
);
range(3);
range(1, 5);
range(1, "foo");
range(1, 5, -1);
The syntax gets a little clumsy, so Safunc offers a shorthand syntax for optional parameters by adding a ?
prefix to the type definition.
const range = def(
sig("integer", "?integer", "?integer>0", "=>", "integer[]"),
function range(startOrStop, stop, step = 1) {
const start = stop === undefined ? 0 : startOrStop;
stop ??= startOrStop;
return Array.from({ length: Math.ceil((stop - start) / step) }, (_, i) => start + i * step);
},
);
This shorthand syntax works well for straightforward string type definitions. For more complex type specifications, the optional
helper function remains a necessity.
const repeat = def(
sig("string", optional({ "n?": "integer>0" }), "=>", "string"),
(s, { n = 2 } = {}) => s.repeat(n),
);
repeat("foo", { n: 0.5 });
Overload Signatures
Safunc supports defining functions with overloaded signatures, which is useful when you want to provide multiple ways to call a function with different sets of parameters.
const repeat = def(
sig("string", "=>", "string"),
sig("integer>0", "string", "=>", "string"),
function repeat(...args) {
const [n, s] = args.length === 1 ? [2, args[0]] : args;
return s.repeat(n);
},
);
repeat();
repeat("foo");
repeat(3, "bar");
repeat(5);
const concat = def(
sig("string", "string", "=>", "string"),
sig("number", "number", "=>", "number"),
(a, b) => (a as any) + (b as any),
);
concat("foo", "bar");
concat(1, 2);
conact("foo", 42);
While using a single signature preserves the parameter names in the type information (as you can see in earlier examples), providing multiple signatures can obscure them. This does not affect the runtime behavior of functions, but it can make the type information less readable. Despite these challenges, Safunc strives to maintain clear type information by deducing parameter names through some type-level magic, resulting in types like ((s: string) => string) & ((n: number, s: string) => string)
for repeat
and similarly for concat
.
However, the inferred parameter names may not always meet your expectations:
const range = def(
sig("integer", "=>", "integer[]"),
sig("integer", "integer", "?integer<0|integer>0", "=>", "integer[]"),
function range(startOrStop, stop?, step = 1) {
const start = stop === undefined ? 0 : startOrStop;
stop ??= startOrStop;
const res: number[] = [];
if (step > 0) for (let i = start; i < stop; i += step) res.push(i);
else for (let i = start; i > stop; i += step) res.push(i);
return res;
},
);
range();
range(3);
range(1, "2");
To ensure the parameters are properly named, use as Sig<...>
to explicitly define the function’s type:
import { def, sig, type Sig } from "safunc";
const range = def(
sig("integer", "=>", "integer[]") as Sig<(stop: number) => number[]>,
sig("integer", "integer", "?integer<0|integer>0", "=>", "integer[]") as Sig<
(start: number, stop: number, step?: number) => number[]
>,
function range(startOrStop, stop?, step = 1) {
},
);
Safunc allows for up to 8 overloaded signatures per function.
Work with Arktype morph
s
Arktype features a powerful tool called morph, which validates a value and parse it into another value. This is akin to z.preprocess()
from Zod, if you are familiar with it.
import { morph } from "arktype";
const stringifiablePrimitive = morph("string | number | bigint | boolean | null | undefined", (x) =>
String(x),
);
stringifiablePrimitive(42);
stringifiablePrimitive(Symbol("foo"));
const dateString = morph("string", (x, problems) =>
isNaN(Date.parse(x)) ? problems.mustBe("a valid date") : new Date(x),
);
dateString("2024-04-06");
dateString("foo");
dateString(42);
Safunc seamlessly integrates with this feature, accurately inferring types when using morph
s:
const dateString = morph("string", (x, problems) =>
isNaN(Date.parse(x)) ? problems.mustBe("a valid date") : new Date(x),
);
const isoDateString = morph("Date", (x) => x.toISOString().slice(0, 10));
const addYears = def(
sig(dateString, "integer", "=>", isoDateString),
function addYears(date, years) {
date.setFullYear(date.getFullYear() + years);
return date;
},
);
expect(addYears("2024-04-26", 1)).toBe("2025-04-26");
In the example above, the implementation of addYears
operates with types (date: Date, years: number) => Date
, while its signature specifies (date: string, years: number) => string
. Conversion between string
and Date
is managed by dateString
and isoDateString
respectively. Safunc ensures the correct inference of function types both inside and outside the function body.
Helper methods
The Safunc
instance contains some helper methods to help you work with the function:
const sig1 = sig("integer", "=>", "integer[]") as Sig<(stop: number) => number[]>;
const sig2 = sig("integer", "integer", "?integer>0", "=>", "integer[]") as Sig<
(start: number, stop: number, step?: number) => number[]
>;
const range = def(sig1, sig2, (startOrStop, stop?, step = 1) => {
});
const unwrappedRange = range.unwrap();
range.matchArguments(3);
range.matchArguments(1, 5);
range.matchArguments("foo");
range.assertArguments(1, 5);
range.assertArguments("foo");
range.allowArguments(1, 5);
range.allowArguments("foo");
Some properties of the Safunc
instance are not recommended for use due to their lack of type safety, though they may still be useful in certain situations:
range.$sigs;
range.$fn;
Arktype Utilities
Safunc provides some handy utilities to easily define some common schemas with Arktype:
import { record, unions } from "safunc";
const recordSchema = record("string", "number");
recordSchema({ foo: 42 });
recordSchema({ foo: "bar" });
const unionsSchema = unions("string", "number", "boolean");
unionsSchema("foo");
unionsSchema(42);
unionsSchema(true);
unionsSchema({});
Using Safunc in Plain JavaScript Files with JSDoc
Using Safunc isn't limited to TypeScript environments. TypeScript supports type annotations in JavaScript through JSDoc, allowing you to maintain type safety in plain JavaScript files. Since Safunc generally doesn't require explicit type annotations, it works seamlessly in most scenarios.
When dealing with overloaded signatures in JavaScript, you can use JSDoc syntax similar to as Sig<...>
in TypeScript to achieve clearer type information:
import { def, sig } from "safunc";
const range = def(
(sig("integer", "=>", "integer[]")),
(sig("integer", "integer", "?integer>0", "=>", "integer[]")),
function range(startOrStop, stop, step = 1) {
},
);
To reduce verbosity, you can define a type alias for Sig<...>
:
const range = def(
(sig("integer", "=>", "integer[]")),
(sig("integer", "integer", "?integer>0", "=>", "integer[]")),
function range(startOrStop, stop, step = 1) {
},
);
Note that you must enclose sig(...)
in parentheses to enable TypeScript to recognize it as a type assertion.
Safunc can also be utilized through CDNs and import maps in modern browsers:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Safunc in Browser</title>
<script type="importmap">
{
"imports": {
"safunc": "https://cdn.jsdelivr.net/npm/safunc/+esm"
}
}
</script>
</head>
<body>
<script type="module">
import { def, sig } from "safunc";
const add = def(sig("number", "number", "=>", "number"), (n, m) => n + m);
console.log(add(1, 2));
add(1, "2");
</script>
</body>
</html>
This setup is particularly useful in non-standard frontend environments, such as within backend projects that use a templating engine.