Adding a second middleware broke our TypeScript types

Linell6 pts1 comments

Adding a second middleware broke our typescript types - Inngest Blog

Open Source6.9KOpen Source6.9K<br>Sign inStart free

Blog ArticleAdding a second middleware broke our typescript types<br>Linell Bonnette•7/13/2026•11 min read

While reading through inngest-js's open issues, I came across one that was pretty surprising — Multiple middlewares corrupt TS typings.

When I pass two middleware to the Inngest client, the return type of step.run collapses to {}. With one middleware, everything works.

type Widget = { media: { mediaId: string; label?: string }[] };<br>const widgetValue: Widget = { media: [] };

const client = new Inngest({<br>id: "my-app",<br>// middleware: [MiddlewareA], // working<br>// middleware: [MiddlewareB], // working<br>middleware: [MiddlewareA, MiddlewareB] // type error!

});

// inside a function…<br>const widget = await step.run("load-widget", async (): PromiseWidget> => {<br>return loadWidget();<br>});

widget.media[0].mediaId;<br>// Property 'mediaId' does not exist on type 'JsonifyObject' 🙃

Delete either of the middleware and the error vanishes. The actual middleware code itself doesn’t matter either, so two no-op middleware still faithfully causes an issue. Somehow the count is what breaks the types.

It was obvious that something suspicious was going, so I slipped into a detective costume and started sleuthing.

Two should just be one, twice

A crucial piece of context is that when an Inngest function runs, the result of every step.run is serialized to JSON and stored, so the step can be memoized across retries. Your function returns a Date but what comes back on replay is a string.

Middleware can transform step outputs, so each middleware carries a static output transform. The default transform is Jsonify and those transforms compose, so two middleware means Jsonify>.

At runtime this is obviously fine: serializing already-serialized data is a no-op.

The type should be idempotent the same way… right?

Instead, this is what happens:

type Widget = { media: { mediaId: string; label?: string }[] };

type Once = JsonifyWidget>; // { media: { mediaId: string; label?: string }[] }<br>type Twice = JsonifyOnce>; // { media: JsonifyObject[] }

Delete label?: string and Twice comes out perfect. The entire failure hangs on one optional property. Huh?

An idiom with a record

Jsonify has to drop properties that don't survive serialization. Things like functions, symbols, and  undefined. The standard idiom for "filter an object's keys by their value type" looks like this:

type FilterJsonableKeysT extends object> = {<br>[Key in keyof T]: T[Key] extends NotJsonable ? never : Key;<br>}[keyof T];

Map each property to its own name if the value is serializable, or never if it isn't, then read every value back out with [keyof T]. For { a: string; b: () => void } the mapped type is { a: "a"; b: never }, reading it back gives "a" | never, never vanishes from unions, and you're left with "a". Feed that to Pick and you're done. You've probably done this before in your own code.

How undefined becomes a key

Mapped types written with [Key in keyof T] preserve each property's modifiers, including the ?. So for our widget's media element, the intermediate object is:

{ mediaId: "mediaId"; label?: "label" }

And reading an optional property in TypeScript includes undefined in what you get back. Read every value out of that object and you get:

"mediaId" | "label" | undefined

An undefined in a list of keys doesn’t seem right, right? It isn't a key. It shouldn’t be a key? But the compiler is completely fine with it.

The loophole

This is what makes the bug so hard to see. The poisoned union feeds Pick:

type JsonifyObjectT extends object> = {<br>[Key in keyof PickT, FilterJsonableKeysT>>]: JsonifyT[Key]>;<br>};

Pick requires K extends keyof T, and normally that constraint has teeth.. Write this yourself, and the compiler rejects it on the spot:

type Broken = PickWidget, "media" | undefined>;

Inside of JsonifyObject though, the same Pick is written against the generic T:

PickT, FilterJsonableKeysT>>

That’s the loophole. The compiler checks a constraint where the code is written, not again each time it’s used. At the definition site, T is still abstract, so there’s no concrete type for optionality to leak an undefined out of, which means that the check passes.

Later, when T is filled in with a real type, the compiler is expanding a definition that it’s already approved. It doesn’t go back and re-check the constraint against the concrete types. Pick stamps out one property per union member and just tolerates the undefined.

What comes out is a type that's only half broken. Property access works, assignability works, and hover looks healthy, so every ordinary interaction with it says that nothing is wrong. But its key set now literally contains undefined. I didn't quite believe that until I made the compiler admit it

declare const once: Jsonify mediaId: string; label?: string }>;

once.mediaId; // string - looks perfectly healthy<br>const k:...

type middleware string mediaid undefined media

Related Articles