Maintaining type safety with anonymous workflow functions for validating workflow input

We use zod heavily to ensure runtime validations align with our compile-time TS types. I created this helper that leverages zod to validate workflow input

import { z, ZodTypeAny } from 'zod'

export const validatedWorkflowInputHelper = <
    TInputSchema extends ZodTypeAny,
    TWorkflowReturn
>(
        inputSchema: TInputSchema,
        workflowFunction: (input: z.infer<TInputSchema>) => Promise<TWorkflowReturn>
    ): (input: z.infer<TInputSchema>) => Promise<TWorkflowReturn> =>
        async input => workflowFunction(inputSchema.parse(input))

Then, a workflow can be created like this

const myWorkflowInputSchema = z.object({ name: z.string })
export const myWorkflow = validatedWorkflowInputHelper(
    myWorkflowInputSchema,
    ({ name }): Promise<string> => `Hello ${name}`
)

It’s not a huge difference, but I believe this is slightly better than the following

const myWorkflowInputSchema = z.object({ name: z.string })
type Input = z.infer<myWorkflowInputSchema>
export const myWorkflow = (input: Input): Promise<string> => {
    const validatedInput = myWorkflowInputSchema.parse(input)
    return `Hello ${name}`
}

When you’re authoring this function, it can be easy to accidentally use input instead of validatedInput. The validatedWorkflowInputHelper seems like a slightly better DX because the unvalidated input is out of scope. We could also type input as any, which would technically be more accurate, but then you would lose type safety when starting the workflow from another TS function.

The problem with using validatedWorkflowInputHelper is that the workflow function becomes an anonymous function, so passing the function directly into executeChild(), for example, results in an error: BadStartChildExecutionAttributes: Required field WorkflowType is not set on command. We can still pass the string "myWorkflow", but then we lose the type safety of options.args.

For now, I’ll move forward with the approach without the helper, but I wonder if anyone has a better approach for typing and validating workflow inputs

Hi, I don’t know much about zod so can’t comment specifically. One idea maybe is to use interceptors for this type of validation as then you could apply it generically to workflow/activity input payloads and results too.

We currently rely on the function name for starting workflow via a function reference.

You’ll need to add that to the helper:

export const validatedWorkflowInputHelper = <
    TInputSchema extends ZodTypeAny,
    TWorkflowReturn
>(
        name: string,
        inputSchema: TInputSchema,
        workflowFunction: (input: z.infer<TInputSchema>) => Promise<TWorkflowReturn>
    ): (input: z.infer<TInputSchema>) => Promise<TWorkflowReturn> => {
        const fn = async input => workflowFunction(inputSchema.parse(input))
        fn.name = name
        return name
    }

Since the client uses the function name and the worker uses the exported name, the two have to match.
If you want to remove this limitation, you’d need to bypass the worker’s workflow resolution and implement a default workflow function (requires sdk >=1.6.0), like so:

// workflow-functions.ts

// name is attached to the returned function and does not have to match the export name
export const myWorkflow = validatedWorkflowInputHelper(
    'myWorkflow',
    myWorkflowInputSchema,
    ({ name }): Promise<string> => `Hello ${name}`
)

// workflows.ts - this goes in WorkerOptions.workflowsPath
import * as workflows from './workflow-functions'

const nameToFn = new Map(Object.values(workflows).filter(isFunction).map((w) => [w.name, w]))
// Optionally, you can warn or throw if a duplicate name is registered.

export default async function (...args: unknown[]): Promise<WorkflowTypeAndArgs> {
  const { workflowType } = workflowInfo()
  const fn = nameToFn.get(workflowType)
  if (fn === undefined) throw new ReferenceError(`Workflow type not registered: ${workflowType}`)
  return await fn(...args)
}