A function declares a zod schema for what it takes and what it returns, and a wrapper parses both on every call. The input and output are checked at runtime, not only in the editor.
The constraint: TypeScript types are for development. They don't validate a function's inputs or outputs once the code runs, so the checking has to be a layer you add on purpose. In @panth977/functions that layer is FUNCTIONS.WRAPPERS.SafeParse.
I'm Panth, and I lead the software team at Oizom. @panth977/functions is one of my open-source libraries on JSR. Once a function is declared this way, three other things come with the same shape: wrappers for caching and throttling without touching the function's code, static variables kept out of the global scope, and a context that travels through the call tree with logs, cleanup and state.
One declaration
const getUser = FUNCTIONS.AsyncFunction.build({
namespace: 'PgQuery', // optional
name: 'getUser', // optional
input: z.object({ userId: z.number().int().gt(0) }),
output: z.object({ name: z.string(), ... }).optional(),
static: { // add static variables
db: new Pg.Pool(...),
},
buildContext: YourCustomContextBuilder, // optional
wrappers: (_params) => [FUNCTIONS.WRAPPERS.SafeParse({ _params })], // optional
async func({ context, input, build }) {
context.log('getting user', input.userId);
const result = await build.db.query(`SELECT * FROM users WHERE id = ? LIMIT 1`, [input.userId]);
const user = result.rows[0];
if (!user) return undefined;
context.log('found user', user.name);
return user;
}
});
...
getUser.setNamespace('PgQuery');
getUser.setName('getUser');
getUser.getRef(); // 'PgQuery.getUser'
...
const user = await getUser({ context, input: { userId } });
What each part is for:
| Field | What it does |
|---|---|
input, output
|
zod schemas. Generators also get yield and next. |
wrappers |
Local middleware: higher-order functions, run in the order you write them. |
static |
Variables attached to the function, like static in a class. Read back through build. |
buildContext |
Your own context builder, or the default one. |
name, namespace
|
For debugging. getRef() returns 'PgQuery.getUser'. |
func |
The implementation, with context, input and build. |
There is a single argument, so every argument is named, and a call reads getUser({ context, input: { userId } }) at every call site. name and namespace don't have to be known when the function is built; they can be set later in the code, which the last section uses. FUNCTIONS builds four kinds: SyncFunction, AsyncFunction, SyncGenerator and AsyncGenerator. The schemas give you types while you write the code. SafeParse is what makes them hold at runtime.
Wrappers
Caching, throttling and logging are usually higher-order functions wrapped around the original. Here they are a list on the declaration, and they run in the order written:
const fib = FUNCTIONS.SyncFunction.build({
input: z.number(),
output: z.number(),
wrappers: (_params) => [
// your custom wrapper
function ({ context, input, func, build }) {
console.log('start w1', input);
const result = func({ context, input, build });
console.log('end w1', input);
return result;
},
function ({ context, input, func, build }) {
console.log('start w2', input);
const result = func({ context, input, build });
console.log('end w2', input);
return result;
},
],
func({ context, input, build }) {
console.log('fib', input);
if (input <= 2) return 1;
return build({ context, input: input - 1 }) + build({ context, input: input - 2 });
},
});
fib({ context, input: 3 });
// CONSOLE
start w1 3
start w2 3
fib 3
start w1 2
start w2 2
fib 2
end w2 2
end w1 2
start w1 1
start w2 1
fib 1
end w2 1
end w1 1
end w2 3
end w1 3
The recursive calls go through build, so each one passes through both wrappers again. That is the point of having them on the declaration: fib itself has no idea that anything is wrapped around it, and you can add or remove a wrapper without editing func. Caching is a wrapper, the runtime check is a wrapper, and so is your own logging. The ready-made ones:
-
FUNCTIONS.WRAPPERS.SafeParseparses input and output. -
FUNCTIONS.WRAPPERS.MemoDataremembers results inAsyncFunctionandSyncFunction. -
FUNCTIONS.WRAPPERS.Debuglogs input and output, and checks whether the call took longer than the throttle time you set. -
FUNCTIONS.WRAPPERS.CloneDatacopies inputs and outputs (and yields and next arguments in a generator).
Static variables
Anything you put in static stays attached to the function, outside the global scope. A query builder, a connection pool, a constant the function needs: it lives with the function, the implementation reads it through build, and code outside can still reach it as a property:
const getUser = FUNCTIONS.AsyncFunction.build({
...
static: {
query: new QueryBuilder(`SELECT * FROM users WHERE ID = {{{SqlNum userId}}}`),
},
...
async func({ context, input, build }) {
const rows = await pg.query(build.query.compile(input))
...
}
});
getUser.query; // QueryBuilder
Context: logs, cleanup and state down the call tree
The context you pass in goes to buildContext, which makes a new one for the call. A context is identified by its id, and several contexts can share an id. I create one context with a unique id per task and pass it to every call under it.
await FUNCTIONS.DefaultContext.Builder.forTask('myId', function (context, done) {
// use parent context as reference
const context1 = FUNCTIONS.DefaultContext.Builder.fromParent(context, 'ref1');
const context2 = FUNCTIONS.DefaultContext.Builder.fromParent(context, 'ref2');
console.log(context.id) // 'myId'
console.log(context.id === context2.id) // true
console.log(context === context2) // false
console.log(context.path) // []
console.log(context2.path) // ['ref1', 'ref2']
done();
}); // create a new context
A context made from a parent keeps the task's id and gets a new object, and its path records the references it came down through. Every call in one task logs and cleans up under the same id.
context.log. console.log can't tell you which task or thread wrote a line. context.log can. Nothing reaches the console until you add a logger:
FUNCTIONS.DefaultContext.onLog(function (context, args) {
console.log(context.id, ...args);
});
// CONSOLE:
(0 sec) myId Hi World
context.dispose. Cleanup that shouldn't slow the main task goes into a dispose callback. Whoever created the context disposes of it:
await FUNCTIONS.DefaultContext.Builder.forTask('myId', function (context, done) {
// just before this function is called, FUNCTIONS.DefaultContext.onCreate is called
console.log('my implementation');
context.dispose(() => TOOLS.delay(2_000).then(() => console.log('MyDispose1')));
context.dispose(() => TOOLS.delay(2_000).then(() => console.log('MyDispose2')));
TOOLS.delay(2_000).then(done);
// after the done call, FUNCTIONS.DefaultContext.onDispose is called
});
console.log('task completed!');
The order: every onCreate hook is awaited (Promise.allSettled), then the task runs. When it calls done, every context.dispose callback is awaited, and then every onDispose hook.
context.useState. Local state belongs to one context instance. Global state is shared by all parent and child contexts. In the example, the child sees the parent's global value but not its local one, and when the child changes the global value, the parent sees the change:
const GlobalStateKey = FUNCTIONS.DefaultContextState.CreateKey<string>({
label: 'MyGlobalState',
scope: 'global',
});
const LocalStateKey = FUNCTIONS.DefaultContextState.CreateKey<number>({
label: 'MyLocalState',
scope: 'local',
});
FUNCTIONS.DefaultContext.Builder.forTask('myId', function (context, done) {
context.useState(GlobalStateKey).set('s1');
context.useState(LocalStateKey).set(1);
const context1 = FUNCTIONS.DefaultContext.Builder.fromParent(context, 'ref1');
const context2 = FUNCTIONS.DefaultContext.Builder.fromParent(context, 'ref2');
console.log(context2.useState(GlobalStateKey).get()) // 's1'
console.log(context2.useState(LocalStateKey).get()) // undefined
context2.useState(LocalStateKey).set(2);
context2.useState(GlobalStateKey).set('s2');
console.log(context.useState(GlobalStateKey).get()) // 's2'
console.log(context.useState(LocalStateKey).get()) // 1
console.log(context2.useState(LocalStateKey).get()) // 2
done();
});
Names from the folder
name and namespace can be set after the build. I export every function in a directory from one index.ts and set them in a loop: the directory is the namespace, the export name is the name.
// --- pg/users.ts ---
export const getUser = FUNCTIONS.AsyncFunction.build({ ... });
export const getSubUsers = FUNCTIONS.AsyncFunction.build({ ... });
// --- pg/devices.ts ---
export const getDevices = FUNCTIONS.AsyncFunction.build({ ... });
export const updateDevice = FUNCTIONS.AsyncFunction.build({ ... });
// --- pg/_index.ts ---
export * from './users.ts';
export * from './devices.ts';
// --- pg/index.ts ---
export * as Pg from './_index.ts';
import * as _Pg_ from './_index.ts';
const Pg = (_Pg_) as Record<string, any>;
for (const name in Pg) {
Pg[name].setNamespace('Pg');
Pg[name].setName(name);
}
Routes in @panth977/routes are built the same way, which the routing post covers.
Do you parse a function's input and output at runtime, or only at the edges of the system, where requests come in?
Top comments (0)