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.
| Code | Check time |
|---|---|
One on() call with the typed middleware | 7.6 s |
Five on() calls (text, photo, voice, document, sticker) | 32.0 s |
| The same five calls with arrow wrappers | 0.6 s |
One call with the plain Context instead of BotContext | 0.47 s |
| One call with an explicit type argument | 0.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 ms99% 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>>>
): thisFilter 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
Filterfrom 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:
| Code | Before | After |
|---|---|---|
One on() call, text | 7.6 s | 0.48 s |
One on() call, 'text' string filter | 4.4 s | 0.25 s |
Five on() calls | 32.0 s | 0.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.jsonAnd to check a change with repeated, alternating runs:
npx whyts compare --baseline before/tsconfig.json --candidate after/tsconfig.jsonwhyts 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.