Wrap Instrumentation Around Results


Here's a way to instrument async calls with performance stats.

I've written previously about how I like to handle operations that may fail in result types. Often, these are async tasks, like network requests. If we're broadly using a Result type as a return value, we are very close to being able to non-invasively wrap a layer of performance data around it. Specifically, we want to know how long the async task takes. Here's a potential implementation.

Again, to show the base Result type:

type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }

This is a container around a value that may be present or an error that may come back instead.

Let's add to that with a new OpsResult type:

type OpsResult<T = unknown, E = Error> = { name: string; start: number; end: number } & Result<T, E>

This intersects with Result, so it is a Result, and it adds new start and end fields for timing data. There's also a name, which can be used as an identifier for the timings in a UI that reports on this data.

Now, we have an async task that takes an unknown amount of time:

const result = await myAsyncTask('arg1', 'arg2')

Now we want to measure it:

const result = await wrapOps('myAsyncTaskId', myAsyncTask, 'arg1', 'arg2')

And here's the full implementation for wrapOps:

async function wrapOps<F extends (...args: any[]) => Promise<Result<any>>>(
  name: string,
  fn: F,
  ...args: Parameters<F>
): Promise<OpsResult<Awaited<ReturnType<F>> extends Result<infer T, infer _E> ? T : never>> {
  const start = performance.now()
  const retVal = await fn(...args)
  const end = performance.now()
  return { name, start, end, ...retVal }
}

The key thing is the inclusion of name, start and end in the return type of OpsResult.

The confusing things here are the TypeScript hoops that are jumped through to make this type safe and useful.

The only generic type is F which refers to the async task function (eg, myAsyncTask). This wrapOps utility must be able to wrap any async function that takes any args and returns a Result. From within this utility, we don't care what the args are and return value are, so we use any.

The return type of wrapOps is important to get right so that typings work at the call site of wrapOps. This type is derived from what is wrapped. Here's the excerpt again:

Promise<OpsResult<Awaited<ReturnType<F>> extends Result<infer T, infer _E> ? T : never>>

We get the return type of the async task function with ReturnType and unwrap the promise to get the return type using Awaited. That return type needs to always be a Result, hence extends Result. But we really want the base T type out of the Result container type. So we use infer to bind the generic T. We have to bind _E too, but this is the error on the Result, and we don't need that in the specification of the Result type. Then comes the ternary pattern match, ? T, meaning that if the unwrapped return type of the function F was a Result, which it should always be, then we want to use its base type T, otherwise, we don't care about use never. In summary, it reads, "Return a Promise of Result of whatever the wrapped function returns."

Now you have start and stop timing attributes on any of the async tasks that you want to monitor.