Adding esbuild as a Jest Transformer
Jest transpiles every test file on the fly, and with babel-jest or ts-jest that transpilation dominates a large suite’s startup. Swapping in an esbuild transformer strips types and JSX in a single Go pass, cutting cold-run time sharply — as long as you keep type-checking as a separate step, because esbuild does not type-check. This guide wires it up. It applies the integrating esbuild with framework toolchains approach to the test runner; the parent overview is esbuild & Turbopack Workflows.
The reason this problem exists is that Jest runs in Node, and Node cannot execute TypeScript or JSX directly. Every file Jest touches — the test, the module under test, and their transitive imports — has to be lowered to something the Node CommonJS or ESM loader accepts before a single expect runs. Jest delegates that lowering to a transformer. babel-jest runs the Babel pipeline, which parses to an AST, applies your preset plugins, and re-prints; ts-jest runs the TypeScript compiler’s transpileModule per file and, in its default configuration, also asks the type system whether the file is well-typed. Both are written in JavaScript and both re-parse code that your editor and your bundler have already parsed. On a suite of a few hundred files that duplicated work is not a rounding error — it is the difference between a two-second and a twenty-second cold run, paid again on every watch-mode restart and every CI shard.
esbuild attacks exactly that cost. It is a parser and code generator written in Go that strips TypeScript annotations and compiles JSX without ever building a semantic model of your program. There is no type-checker to run, no symbol table to resolve across files, no plugin graph to walk — it reads the file, discards the type syntax, emits JavaScript, and moves on. That is why it is roughly an order of magnitude faster than the JavaScript-based transformers, and also exactly why it is unsafe to use as a drop-in without a plan: the checks ts-jest folded into the same command have to be reintroduced somewhere else. The whole design of this setup is to separate the two jobs ts-jest conflated — transpile and type-check — run the first with esbuild inside Jest and the second with tsc beside it, so the run is fast without silently going blind to type errors.
Where this sits in the pipeline matters. The transformer runs at the boundary between Jest’s module loader and your source: it is invoked lazily, once per unique file, the first time that file is required in a worker, and the result is cached on disk keyed by file contents and transformer config. It is not a bundler pass — Jest still resolves and links modules itself — so the transformer only ever sees one file at a time and has no cross-file knowledge. Keep that single-file, no-graph view in mind; it explains most of the edge cases below, from why type errors slip through to why decorator metadata needs extra configuration.
tsc pass.Prerequisites & reproducible setup
# Jest 29.x, esbuild 0.25.x, Node 18+
mkdir jest-esbuild-demo && cd jest-esbuild-demo && npm init -y
npm install --save-dev jest@29 esbuild@0.25.0 esbuild-jest-transform
# A TS test that ts-jest would transpile slowly.
mkdir -p src && printf "export const add = (a: number, b: number) => a + b;\n" > src/add.ts
printf "import { add } from './add'; test('add', () => expect(add(2,3)).toBe(5));\n" > src/add.test.ts
A Jest transformer is a module exposing process(src, filename) that returns transpiled code. Several esbuild-based transformers exist; the key is that it must produce the module format Jest expects (CommonJS by default) and inline source maps.
The demo above is deliberately minimal, but it is enough to see the mechanism. src/add.ts uses a type annotation that Node cannot run; src/add.test.ts imports it. Under ts-jest both files would go through transpileModule and, unless you disabled it, a per-file diagnostic pass. Under the esbuild transformer both files go through a single esbuild.transformSync call that returns runnable JavaScript with the annotations gone. The observable behaviour is identical — the test passes — but the work done to get there is much smaller. Pin the exact versions shown in the comment: transformer packages track esbuild’s transform options closely, and a mismatched esbuild minor can change the shape of the options object a transformer forwards.
If you would rather not add a third-party transformer at all, the contract is small enough to write inline. A file exporting process that calls esbuild directly is a complete, supported transformer:
// esbuild-transformer.cjs — Jest 29.x, esbuild 0.25.x. Zero-dependency transformer.
const { transformSync } = require('esbuild');
module.exports = {
// Jest 29 uses process() for sync transforms; return { code }.
process(sourceText, sourcePath) {
const result = transformSync(sourceText, {
loader: sourcePath.endsWith('x') ? 'tsx' : 'ts',
format: 'cjs', // Jest's default module system
target: 'node18', // match the Node you test on
sourcemap: 'inline', // stack traces point at the .ts source
sourcefile: sourcePath, // so the map names the real file
});
return { code: result.code };
},
};
Writing it yourself removes a dependency and a layer of indirection, and it makes the loader-per-extension decision explicit rather than hidden inside a package’s defaults. The trade is that you now own the compatibility surface: if Jest changes the transformer interface (as it did across the 27→28→29 line, moving from a bare return string to a { code } object and from process toward processAsync for ESM), you update this file instead of upgrading a package. For most projects a maintained transformer is the right call; keep the inline version in your back pocket for when you need to debug what a wrapper is actually passing to esbuild.
Diagnosis workflow
- Measure the baseline.
time npx jestwithts-jest— the transpile time is most of a cold run’s startup on a large suite. Run it twice: the first run pays the transformer cache cost you are trying to eliminate, so a cold comparison means clearing Jest’s cache withnpx jest --clearCachebefore each timed run. Note the wall-clock total and, if you want a sharper picture, the reporter’s per-file setup time. The number you care about is the gap between “process started” and “first test executed”, because that gap is almost entirely transpilation and is what esbuild collapses. If that gap is already small — a handful of files, or a suite dominated by slow async tests rather than startup — esbuild will not move your total meaningfully and the swap is not worth the config surface. - Confirm what you lose. esbuild does not type-check; a test that relied on
ts-jestcatching a type error will now pass despite it. Prove this to yourself before you rely on it: add a deliberate type error to a file under test — assign astringto anumber, call a function with a missing argument — and watch the suite stay green under the esbuild transformer whiletsc --noEmitgoes red. This is not a bug to work around; it is the contract. esbuild parses the type syntax well enough to delete it and refuses to reason about whether it was correct. Any safety you had fromts-jest’s default checking now lives entirely in the separatetscstep, and if that step is missing, the safety is simply gone. - Match the module format. If the project is CommonJS, the transformer emits CJS; for
"type": "module", configure Jest’s ESM mode and emit ESM. Getting this wrong produces the two most common failure signatures. Emit CJS into an ESM-configured Jest and you getexports is not definedorrequire is not definedat module load; emit ESM into default CJS Jest and you getCannot use import statement outside a moduleorUnexpected token 'export'. The format the transformer emits (format: 'cjs'vs'esm') must agree with how Jest is loading the module, which is governed by yourpackage.json"type"field and whether you launched Jest withNODE_OPTIONS=--experimental-vm-modules. Decide which world you are in first, then set the transformer to match.
How it works under the hood
When Jest needs a module it has not yet loaded, its resolver finds the file on disk and hands the raw source to the transform layer before the module registry ever sees it. The transform layer consults a content-addressed cache: it hashes the file’s bytes together with a fingerprint of the transformer’s configuration and looks for that key under Jest’s cache directory (by default a temp path per project, overridable with cacheDirectory). On a hit it reads the cached JavaScript straight off disk and skips esbuild entirely; on a miss it calls your transformer’s process, stores the result under the key, and returns it. This is why the second run of an unchanged suite is fast under any transformer — the expensive work is memoised — and why the honest benchmark clears the cache. It is also why changing a single transform option invalidates every cached entry at once: the config fingerprint is part of the key, so bumping target from node18 to node20 forces a full recompile on the next run.
Inside a single call, esbuild does far less than a compiler. It tokenises the source, builds a syntax tree without resolving any identifiers to declarations, deletes every node that is pure type syntax — annotations, interface and type declarations, import type specifiers, as casts, generic parameters — and lowers JSX to React.createElement or the automatic-runtime _jsx calls depending on configuration. Because it never resolves imports across files, it cannot know whether a name you reference is a type or a value that happens to be erased; TypeScript’s isolatedModules model exists precisely to forbid the constructs esbuild cannot disambiguate, and enabling "isolatedModules": true in your tsconfig.json makes tsc flag anything the transformer would silently miscompile. Treat isolatedModules as mandatory the moment you adopt any single-file transpiler; without it, a re-export of a type without the type keyword can compile to code that tries to import a runtime binding that does not exist.
The output is CommonJS by default because that is what Jest’s classic module runner expects: a module.exports object it can populate and hand to require. The inline source map esbuild appends is what keeps stack traces honest — a failing expect reports a line in your original .ts file rather than in the generated JavaScript, which only works if sourcemap: 'inline' (or the transformer’s equivalent) is set and the map’s sourcefile names the real path. Drop the source map to shave a few milliseconds and every assertion failure will point at meaningless generated line numbers; it is almost never worth it.
Annotated solution config
// jest.config.js — Jest 29.x, esbuild transformer.
export default {
// Route TS/TSX/JS/JSX through the esbuild transformer.
transform: {
'^.+\\.(t|j)sx?$': ['esbuild-jest-transform', {
target: 'node18', // match your Node under test
loader: 'tsx', // parse TS + JSX
sourcemap: true, // inline maps for readable stack traces
}],
},
// Do not transform node_modules unless a dep ships untranspiled ESM.
transformIgnorePatterns: ['/node_modules/(?!(some-esm-only-dep)/)'],
testEnvironment: 'node',
};
// package.json — keep type-checking as a separate, required script.
{
"scripts": {
"test": "jest",
"typecheck": "tsc --noEmit",
"ci": "npm run typecheck && npm run test" // both gates in CI
}
}
Two details in the jest.config.js above earn their place. The transform regex ^.+\.(t|j)sx?$ matches .ts, .tsx, .js, and .jsx; route all four through esbuild rather than only the TypeScript extensions, because a mixed codebase will otherwise fall back to Jest’s default babel-jest for the .js files and you will have two transpilers and two caches doing the same job at different speeds. The loader: 'tsx' value tells esbuild to parse both TypeScript and JSX in every file; it is safe on plain .ts because JSX-free code simply contains no JSX to lower, and it saves you from branching the loader on extension inside the config. If you have hand-written the transformer, that branch is the one place a per-extension loader earns its keep, since a .ts file that contains a <Type>value cast would be misread as JSX under the tsx loader.
The transformIgnorePatterns line is the one most projects eventually have to touch. By default Jest skips transforming anything under node_modules, on the assumption that published packages already ship runnable JavaScript. That assumption breaks for dependencies that publish only untranspiled ESM; the negative-lookahead pattern (?!(some-esm-only-dep)/) carves an exception so the named package is fed through the esbuild transformer and down-levelled to whatever Jest can run. Keep the list tight — every package you un-ignore is more work per cold run — and reach for it only when a specific import throws a syntax error from inside node_modules.
The package.json scripts encode the whole thesis of this setup in three lines. test runs the fast, type-blind suite; typecheck runs the type gate esbuild abdicated; ci runs both, in that order, so a type regression fails the pipeline even though it would never fail a test. Putting typecheck first is a small optimisation: tsc is usually the slower of the two now, so failing fast on a type error before spinning up Jest workers saves CI minutes on the common broken-PR case.
Verification
# Jest 29.x — confirm the suite passes and runs faster.
time npx jest # compare cold-run time against the ts-jest baseline
npx tsc --noEmit # the type gate still catches type errors
The suite passes and the cold run is markedly faster than with ts-jest, while tsc --noEmit still fails on a genuine type error — so you kept correctness and gained speed. If a test that should fail on a type error passes, that is expected: type errors are now the typecheck script’s job.
Read the two signals as a pair rather than in isolation. A green Jest run alone no longer means “the code is correct” the way it arguably did under checking ts-jest; it means “the code runs and the assertions hold”. Correctness of types is a separate assertion made by tsc, and only the conjunction of the two is equivalent to what you had before. The failure mode to watch for is a pipeline where someone, chasing green, quietly drops the typecheck step or lets it become non-blocking; the suite stays fast and green, type errors accumulate unnoticed, and the first symptom is a runtime crash in production on a shape mismatch that a compiler would have caught months earlier. Make the typecheck job required, not advisory.
To confirm the speed win is real and not cache-warmed, time a genuinely cold run on each side: npx jest --clearCache && time npx jest under the esbuild transformer versus the same under ts-jest. Expect the transpile-bound portion of startup to shrink by roughly five to ten times; the total suite time shrinks by less, because your actual test bodies — DOM rendering, async waits, I/O mocks — run at the same speed regardless of transformer. If the total barely moves, your suite was never transpile-bound and the honest conclusion is to leave ts-jest in place.
tsc gate is the complete success condition.Gotchas & edge cases
tsc step is the mistake that quietly removes type safety from the project.- No type-checking in tests. Symptom: a pull request that assigns the wrong type or drops a required argument passes the whole suite green. Root cause: esbuild strips type syntax without validating it, so the check
ts-jestused to perform inside the run no longer happens anywhere by default. Fix: addtsc --noEmitas a required CI job and enable"isolatedModules": truesotscalso flags any construct the single-file transform would miscompile. Confirm: introduce a deliberate type error and verify thetypecheckjob fails while Jest still passes — if both pass, your type gate is not wired in. - ESM-only dependencies. Symptom: an import from
node_modulesthrowsUnexpected token 'export'orCannot use import statement outside a modulemid-run. Root cause: the dependency publishes untranspiled ESM and Jest’s defaulttransformIgnorePatternsskips everything undernode_modules, so it reaches the CommonJS loader un-lowered. Fix: add a negative-lookahead exception like'/node_modules/(?!(the-esm-dep)/)'so the transformer down-levels that package too. Confirm: re-run; the import resolves, and add each further offender to the same alternation rather than un-ignoring all ofnode_modules. - Decorators need
tsconfigRaw. Symptom: an Angular or NestJS-style class compiles but dependency injection fails at runtime because the parameter metadata is missing. Root cause: esbuild does not read yourtsconfig.jsonon its own, soexperimentalDecoratorsandemitDecoratorMetadataare off unless you hand them over. Fix: pass them through the transformer’stsconfigRawoption ({ compilerOptions: { experimentalDecorators: true, emitDecoratorMetadata: true } }). Confirm: a test that resolves an injected dependency now succeeds; note that esbuild emits legacy decorator metadata only, so heavy reflection-based frameworks may still need their own toolchain. jest.mockhoisting. Symptom: ajest.mock('./thing')call appears to run after the import it should intercept, and the real module loads instead of the mock. Root cause: Jest’s babel plugin hoistsjest.mockabove imports in CJS output; that hoisting is a property of the CommonJS module runner, not of esbuild. Fix: keep the transformer emitting CJS so hoisting keeps working; if you deliberately move to ESM mode, switch tojest.unstable_mockModuleplus dynamicimport()per Jest’s ESM mocking rules, because static hoisting does not exist there. Confirm: assert the mock’s function is the one called; if the real implementation runs, you are almost certainly emitting ESM into a CJS-mocking test.
Performance considerations
The speed-up is real but bounded, and knowing the bound stops you from over-selling the change. esbuild removes transpile time, and only transpile time. In a suite where startup is dominated by parsing hundreds of TypeScript files, that is most of the wall clock and the win is large. In a suite dominated by slow test bodies — real timers, network mocks that sleep, jsdom rendering large trees — transpilation was already a small fraction and the total will barely shift. Profile before and after with a cache clear on each side so you are measuring the thing you changed. A second, subtler cost lives in Jest’s worker model: each worker process compiles its own copy of a file on first touch and maintains its own on-disk cache read, so on a many-core CI runner the first shard pays the transform cost in parallel across workers. esbuild’s per-file speed keeps that cheap, but it is why the first run after a cache clear is slower than warm runs regardless of transformer, and why --maxWorkers tuning interacts with transform cost. Finally, remember that moving type-checking to tsc does not delete that work — it relocates it. Your CI now runs a full-program tsc pass that ts-jest’s per-file checking never did as a whole; that pass is often slower in isolation than the old checked test run, but it runs once, in parallel with tests, and catches cross-file type errors the per-file checker could miss.
When not to use this
Skip the esbuild transformer when the suite is not transpile-bound: a small project, or one where tests spend their time in asynchronous work rather than startup, gains nothing measurable and adds a config surface and a second failure mode. Skip it when your tests genuinely depend on ts-jest’s in-run type checking as the only type gate and adding a tsc job is not on the table — losing that check without replacing it is a regression, not a speed-up. Be cautious when your code leans hard on features esbuild lowers differently or not at all: const enum inlining across files, legacy-decorator emit for reflection-heavy frameworks, or emitDecoratorMetadata semantics that differ from tsc’s. In those cases either accept the extra tsconfigRaw configuration and verify the emit matches, or keep ts-jest for the affected packages and use esbuild everywhere else — Jest’s transform map is per-pattern, so a mixed setup is legitimate. The decision rule is blunt: adopt esbuild here only when a measured cold run proves transpilation is your bottleneck and a separate tsc gate is already part of CI.
Comparison with ts-jest and @swc/jest
ts-jest is the incumbent it replaces, and the difference is architectural rather than incremental. ts-jest runs the TypeScript compiler, so in its checked mode it is the only option here that validates types inside the test command; that safety is exactly what makes it slow, because it builds a semantic model esbuild never does. If you value one command that both runs and type-checks and can afford the time, ts-jest is defensible. Everyone else pays for checking they then repeat in CI. @swc/jest sits between the two: like esbuild it strips types without checking and is written in a fast native language (Rust), so its performance profile and its “add a separate tsc” requirement are the same as esbuild’s. The practical choice between swc and esbuild usually comes down to which one your project already depends on elsewhere — if your bundler or Next.js pipeline is already on swc, matching the test transformer to it keeps one transpiler’s JSX and decorator quirks in play instead of two; if your build is on esbuild, the same logic points the other way. All three read the same .ts source; what differs is whether they check it (only ts-jest) and how fast they discard the types (esbuild and swc win decisively).
Related
- Integrating esbuild with framework toolchains — the parent cluster on slotting esbuild into toolchains.
- Replacing babel-loader with esbuild in a CRA project — the same speed-for-types trade in webpack.
- esbuild transform API for TypeScript stripping — the transform the transformer calls under the hood.