Blog, TypeScript performance

A 32-second TypeScript check, and the one NoInfer that fixed it

Telegraf is a popular library for Telegram bots. In issue #2016, a user reported that one line made their type check take more than eight seconds:

interface BotContext extends Context {
  customProp: string
}

const composer = new Composer<BotContext>()

const middleware = (ctx: BotContext, next: () => Promise<void>) => next()

composer.on(message('text'), middleware)

Wrapping the middleware in an arrow function made the problem go away:

composer.on(message('text'), (ctx, next) => middleware(ctx, next))

The issue was open for two years. Another user wrote that it "drove me crazy for two days". I wanted to know why, so I measured it.

The numbers

All measurements are tsc --extendedDiagnostics check time on a GitHub Actions runner, TypeScript 5.9.3, strict, three runs each.

CodeCheck time
One on() call with the typed middleware7.6 s
Five on() calls (text, photo, voice, document, sticker)32.0 s
The same five calls with arrow wrappers0.6 s
One call with the plain Context instead of BotContext0.47 s
One call with an explicit type argument0.32 s

So the cost is per call, it needs a custom context, and it goes away when TypeScript does not have to work out the type argument itself.

What the trace shows

tsc --generateTrace records how long each check takes and which type comparisons happen inside it. I used whyts to read the trace. For the single call it showed:

composer.on(message('text'), middleware)    9165 ms
  recorded type comparisons inside it         64 ms

99% of the time of that call is not in any recorded type comparison. The trace records comparisons, but not type inference. That points to inference.

The signature of on is:

on<Filter extends tt.UpdateType | Guard<C['update']>>(
  filters: MaybeArray<Filter>,
  ...fns: NonemptyReadonlyArray<Middleware<FilteredContext<C, Filter>>>
): this

Filter should come from the first argument, message('text'). But middleware is a function with its own parameter type, so TypeScript also tries to infer Filter from it. To do that, it walks BotContext member by member against FilteredContext<C, Filter>, which is Context<U> & Omit<C, keyof Context> for a large union U. That walk is the 9 seconds.

This also explains the other rows:

  • An arrow function without parameter types is "context sensitive". TypeScript skips it in the first inference pass and fixes Filter from the filter first.
  • With the plain Context, both sides are references to the same generic class, so TypeScript compares the type arguments only.
  • With an explicit type argument there is nothing to infer.

Two fixes that did not work

My first try was to stop inference at Filter:

...fns: NonemptyReadonlyArray<Middleware<FilteredContext<C, NoInfer<Filter>>>>

whyts compare (five alternating runs per side): baseline median 36.10 s, candidate 36.14 s, "within noise". TypeScript still walks the whole context type to get to the NoInfer deep inside it.

The older idiom [T][T extends any ? 0 : never] around the middleware type did not change the time either.

The fix that worked

Wrap the whole parameter type, so inference stops at the top:

...fns: NonemptyReadonlyArray<NoInfer<Middleware<FilteredContext<C, Filter>>>>

Built from Telegraf's source, before and after:

CodeBeforeAfter
One on() call, text7.6 s0.48 s
One on() call, 'text' string filter4.4 s0.25 s
Five on() calls32.0 s0.6 s

whyts compare on the five calls: median 31.83 s before, 0.63 s after, and the ranges of the runs do not overlap.

Type errors stay the same. An inline handler still gets ctx.message.text as string. A middleware written for photo updates and passed to a text filter still gives TS2769. Telegraf's tests and lint pass. The fix is proposed in telegraf#2100. It needs TypeScript 5.4 or later in projects that use the typings, which is for the maintainers to decide.

The rule

If a generic function takes a callback, and the type parameter should come from another argument, check what happens when a caller passes a function that already has a type. If the callback's parameter type is large, inference from it can cost seconds per call. NoInfer around the whole callback type stops it. NoInfer around the type parameter deep inside does not.

Finding this in your project

whyts now recognizes this pattern. On the Telegraf case, version 0.8.10 reports:

1. [measured] on() infers Filter from the function argument middleware:
   composer.on(message('text'), middleware)
   The check of this call took 10539.7 ms. Recorded type comparisons and
   nested checks cover 252.9 ms of it (2.4%).
   Fix: In the declaration of on, wrap the element type of ...fns in
   NoInfer<...> (TypeScript 5.4 or later). Wrap the whole type, not only
   Filter inside it.

It says plainly that this is a match of the call shape, because the trace has no event for inference. To try it:

npx whyts --project tsconfig.json

And to check a change with repeated, alternating runs:

npx whyts compare --baseline before/tsconfig.json --candidate after/tsconfig.json

whyts also runs as an MCP server, so a coding agent can measure before and after a type change instead of guessing.

whyts is open source: github.com/musatoktas/whyts. More posts are under Blog.