DEV Community

Cover image for Zod input and output validation for JavaScript functions
Panth Patel
Panth Patel

Posted on Originally published at panth.vardayinitech.in

Zod input and output validation for JavaScript functions

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 } });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.SafeParse parses input and output.
  • FUNCTIONS.WRAPPERS.MemoData remembers results in AsyncFunction and SyncFunction.
  • FUNCTIONS.WRAPPERS.Debug logs input and output, and checks whether the call took longer than the throttle time you set.
  • FUNCTIONS.WRAPPERS.CloneData copies 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!');
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)