Performance LabLab #001

Typed events: the elegant type that slowed down every call site

Does a mapped type with an as clause, indexed at every call site, make type-checking measurably slower than inferring the event name from a generic parameter?

Status
Confirmed
Published
Last updated
On this page

Abstract

Does a mapped type with an as clause, indexed at every call site, slow type-checking down compared with inferring the event name from a generic parameter? The same app was checked before and after the change with TypeScript 6 and TypeScript 7, on an Apple M3 Pro and an Intel Xeon, with a safety file of usages that must still fail to compile. On the Apple M3 Pro, check time fell by 52% with TypeScript 6 and by 57% with TypeScript 7, with the same type safety. The main limit: one app written for the experiment, measured with the earlier protocol, without a warm-up run or interleaving.

Key numbers

−52%

check time with TypeScript 6 after the fix

median check time, after vs before
Apple M3 Pro,

−57%

check time with TypeScript 7 after the fix

median check time, after vs before
Apple M3 Pro,

−65%

check time with TypeScript 7 after the fix, on one core

median check time, after vs before
Intel Xeon,

1Introduction

Typed event emitters are everywhere in TypeScript code: a map from event names to payload types, and an on method that looks up the payload for each name. Deriving the names with a mapped type and an as clause is concise and reads well, but the checker has to evaluate that type again at every call site that indexes it.

A cost paid per call site grows with the codebase and is paid on every type-check, locally and in CI. This experiment measures whether that cost is large enough to matter, and whether a simpler pattern removes it without weakening the types.

2Method

Subject

A small app written for the experiment, with many typed settings and listeners. In before/, event names come from a mapped type with an as clause, and the tsconfig has no include, so a generated file the app never imports is checked too. In after/, the event name is inferred from the generic parameter, and include is narrowed to src. safety.ts lists wrong usages that must still fail to compile with the fixed version.

Code: experiments/001-typed-events in the lab repository. Each run is tsc -p <project> --extendedDiagnostics.

Environment and protocol, per machine

Apple M3 Pro, 12 coresMeasured

CPU
Apple M3 Pro
Cores
12
RAM
36 GB
OS
Darwin 25.2.0
Node.js
22.23.3
Compilers
TypeScript 6.0.3, TypeScript 7.0.2
Protocol
Runs per condition
3
Warm-up
None
Order
In sequence (A A A B B B)
Cooldown
Not recorded
Type safety
Passed: invalid usages still fail to compile
Conditions
  • before (TypeScript 6.0.3), 3 runs
  • after (TypeScript 6.0.3), 3 runs
  • before (TypeScript 7.0.2), 3 runs
  • after (TypeScript 7.0.2), 3 runs

Intel Xeon, 1 coreMeasured

CPU
Intel Xeon Processor @ 2.30GHz
Cores
1
RAM
4 GB
OS
Linux 6.18.44-fc-v230
Node.js
22.22.2
Compilers
TypeScript 6.0.3, TypeScript 7.0.2
Protocol
Runs per condition
3
Warm-up
None
Order
In sequence (A A A B B B)
Cooldown
Not recorded
Type safety
Passed: invalid usages still fail to compile
Conditions
  • before (TypeScript 6.0.3), 3 runs
  • after (TypeScript 6.0.3), 3 runs
  • before (TypeScript 7.0.2), 3 runs
  • after (TypeScript 7.0.2), 3 runs

Metrics

Check time
The "Check time" line printed by tsc --extendedDiagnostics, in seconds: the time spent in the type checker only.
Total time
The "Total time" line printed by tsc --extendedDiagnostics, in seconds: parsing, binding and checking together.
Median
The middle value of the measured runs, or the mean of the two middle values for an even count. Every reported figure is a median.
Range
The fastest and slowest measured runs, shown as whiskers on the charts.
IQR
Interquartile range, Q3 minus Q1, with quartiles by linear interpolation between order statistics.
CV
Coefficient of variation: sample standard deviation divided by the mean. Above 5%, the cause is investigated and logged.
Cut
One minus the ratio of the median check time after the fix to the median before it. Its range uses the fastest and slowest runs of each side.

The full protocol is on the method page.

3Results

Apple M3 Pro, 12 coresEarlier protocol: no warm-up, in sequence,

Median check time, typed events app, before and after the fix, Apple M3 Pro, Nov 9, 2026

Bar: median. Whisker: fastest to slowest run.

Data table
ConditionRunsMedianMinMaxIQRCV
before (TypeScript 6.0.3)30.890 s0.890 s0.910 s0.010 s1.3%
after (TypeScript 6.0.3)30.430 s0.420 s0.440 s0.010 s2.3%
before (TypeScript 7.0.2)30.330 s0.326 s0.330 s0.002 s0.7%
after (TypeScript 7.0.2)30.143 s0.117 s0.159 s0.021 s15.2%
Cut in check time after the fix, Apple M3 Pro
CompilerCutConservative range
TypeScript 6−52%51% to 54%
TypeScript 7−57%51% to 65%
Check time per condition, Apple M3 Pro, 12 cores
ConditionRunsMedianMinMaxIQRCVTotal timemedianPeak RSSmedian
before (TypeScript 6.0.3)30.890 s0.890 s0.910 s0.010 s1.3%1.10 s282 MiB
after (TypeScript 6.0.3)30.430 s0.420 s0.440 s0.010 s2.3%0.560 s225 MiB
before (TypeScript 7.0.2)30.330 s0.326 s0.330 s0.002 s0.7%0.349 s200 MiB
after (TypeScript 7.0.2)30.143 s0.117 s0.159 s0.021 s15.2%0.166 s112 MiB
Compiler counters and reported memory
ConditionMemory reportedby the compiler, medianInstantiationsTypesFiles
before (TypeScript 6.0.3)170 MiB173,30738,33665
after (TypeScript 6.0.3)124 MiB64,16633,78564
before (TypeScript 7.0.2)89 MiB260,79155,53765
after (TypeScript 7.0.2)70 MiB142,95247,72464

Memory reported is the compiler's own "Memory used" line, a different metric for each compiler. Counters were identical across runs of each condition.

Intel Xeon, 1 coreSecondary machine, own scales. Earlier protocol: no warm-up, in sequence,

Median check time, typed events app, before and after the fix, Intel Xeon, Nov 3, 2026

Bar: median. Whisker: fastest to slowest run.

Data table
ConditionRunsMedianMinMaxIQRCV
before (TypeScript 6.0.3)33.55 s3.49 s3.59 s0.050 s1.4%
after (TypeScript 6.0.3)32.11 s2.07 s2.17 s0.050 s2.4%
before (TypeScript 7.0.2)30.927 s0.886 s0.932 s0.023 s2.8%
after (TypeScript 7.0.2)30.326 s0.303 s0.344 s0.020 s6.3%
Cut in check time after the fix, Intel Xeon
CompilerCutConservative range
TypeScript 6−41%38% to 42%
TypeScript 7−65%61% to 67%
Check time per condition, Intel Xeon, 1 core
ConditionRunsMedianMinMaxIQRCVTotal timemedianPeak RSSmedian
before (TypeScript 6.0.3)33.55 s3.49 s3.59 s0.050 s1.4%4.53 sn/a
after (TypeScript 6.0.3)32.11 s2.07 s2.17 s0.050 s2.4%2.76 sn/a
before (TypeScript 7.0.2)30.927 s0.886 s0.932 s0.023 s2.8%1.01 sn/a
after (TypeScript 7.0.2)30.326 s0.303 s0.344 s0.020 s6.3%0.386 sn/a
Compiler counters and reported memory
ConditionMemory reportedby the compiler, medianInstantiationsTypesFiles
before (TypeScript 6.0.3)176 MiB173,30738,33665
after (TypeScript 6.0.3)114 MiB64,16633,78564
before (TypeScript 7.0.2)88 MiB260,79155,53765
after (TypeScript 7.0.2)70 MiB142,95247,72464

Memory reported is the compiler's own "Memory used" line, a different metric for each compiler. Counters were identical across runs of each condition.

4Discussion

The fix pays off with both compilers. TypeScript 7 does not make the costly pattern harmless: it still evaluates the mapped type at each call site, so the simpler pattern keeps its advantage after a migration.

On the Apple M3 Pro the ranges of the two cuts overlap (51% to 54% with TypeScript 6, 51% to 65% with TypeScript 7), so this protocol cannot say which compiler gains more there. On the Intel Xeon they do not overlap (38% to 42% and 61% to 67%).

Lab #002 measures the other lever, the compiler itself, on a real library. The two are independent: a type pattern that is slow to check costs time with either compiler, and fixing it adds to the gain of the migration.

5Limitations and threats to validity

  • Both machines use the earlier protocol: 3 runs per condition, no warm-up, runs in sequence. On the Apple M3 Pro, the runs of TypeScript 7 after the fix vary more than the others (0.117 s to 0.159 s, 15.2% coefficient of variation), hence the wider range on that figure.
  • The two machines differ by CPU as well as core count: compare the before and after conditions within a machine.
  • Results come from one machine per session: compare conditions within the same machine only.

6Conclusion and recommendations

Yes. On an Apple M3 Pro (12 cores), inferring the event name from the generic parameter cut check time by 52% with TypeScript 6 and by 57% with TypeScript 7 (51% to 65% from the fastest and slowest runs); on a 1-core Intel Xeon, by 41% and 65%. The safety check passed on both machines, so the type safety is the same. Narrowing include to src also stopped TypeScript from reading a generated file the app never imports.

A mapped type with an as clause is evaluated backwards at each indexed call site. If you can infer the key from a generic instead, the checker does far less work.

Recommendations

  1. Look for mapped types with an as clause that are indexed at many call sites, and infer the key from a generic parameter instead.
  2. Keep a safety file of usages that must still fail to compile, so the faster version is proven to be as strict.
  3. Check that include covers only the code the app compiles: here a generated file the app never imports was type-checked on every run.

7Reproduce

Node.js 22 or later and git. From the repository root, run the experiment, then recompute every statistic from the results files. New results land in experiments/001-typed-events/results/: open a pull request with yours to add a machine.

npm install && npm run lab:001
node experiments/001-typed-events/analyze.mjs

8Data

All raw files: results folder on GitHub. Everything shown on this page: data.json.