Node does not execute TypeScript directly, yet that is exactly what you write your tests in - so a transformer must stand between the source and the runtime. A transformer is the step that turns TypeScript and JSX into JavaScript your current Node understands. From this grow two independent forks: what to compile the types with, and which module format to run the code in - the familiar CommonJS or native ESM. Continue the running example: the calculateDelivery function lives in a TypeScript module, and the choices at these forks decide whether the test builds at all.
The natural but harmful impulse is to turn everything on at once. Wire up both ts-jest and babel, check types inside the tests, freely mix require and import. In reality double transformation slows the run several times over, and errors become unreadable - it is unclear whose stack trace this is, the type compiler's or the transpiler's. Mixing formats gives the worst of it: a module loads sometimes and not others.
The rule is simple: choose one path and be consistent. The first fork is the transformer. It has two reasonable answers, and the difference between them is not idle speed but whether types are checked during the test run.
The first answer is ts-jest. It is convenient when you need tight integration with TypeScript: ts-jest compiles the test through the real TypeScript compiler and checks types along the way, so a type mismatch fails the test just like a runtime error. You configure it through a preset - the config:init command creates a starting point. The key rule: do not duplicate transform by hand if the preset already set up compilation, or you get that very double transformation.
The second answer is Babel or @swc. They do not compile types, they strip them: stripping types means mechanically removing annotations without any checking, which makes the transformation very fast. The cost is that tests stop catching type errors - a green run says nothing about type correctness. That is why the type-check is moved into a separate tsc --noEmit command, run alongside the tests, usually in the same CI step.
The choice comes down to priority. ts-jest gives confidence in types inside the test at the cost of speed; Babel and @swc give speed at the cost of blindness to types, which you compensate with a separate tsc --noEmit. For a small project with slow CI the second path is noticeably nicer; where types are the main contract and you want to catch them right in the run, the first is justified. There is only one bad option - taking both.
The second fork is the module format. CommonJS is Node's old format, where modules are pulled in through require and module.exports. ESM is the language standard with import and export. Native ESM in Jest requires agreement among three things: a package.json with a type module field or file extensions, a transformer configured to emit ESM, and a Node flag that raises experimental module support in the virtual machine. Let one of these fall out of sync and the module stops loading.
A separate ESM trap is mocks. The classic jest.mock is built for CommonJS: the call is hoisted to the top of the file above the imports because require executes lazily. In native ESM imports are statically bound before any code, and hoisting no longer saves you. That is why for ESM you use jest.unstable_mockModule and a dynamic import() that pulls the module in after the mock is installed. You cannot mechanically mix CommonJS mocks with ESM imports - the typical symptom: the mock seems ignored because the module was bound before the mock took its place.
npm i -D jest ts-jest @types/jest typescript
npx ts-jest config:initnpm i -D jest babel-jest @babel/core @babel/preset-env \
@babel/preset-typescript
// babel.config.cjs
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
}