Skip to content
Back to Blog
engineering testing typescript monorepo

The Contracts Package Problem

How a shared TypeScript package broke our tests and our runtime in different ways — and the surprisingly narrow fix that satisfied both.

QuickBridge Team |

We have a shared @packages/contracts package in our monorepo. It exports Zod schemas — ConnectionSchema, IntegrationSchema, AuditEventType — that are consumed by the Tunnel (Node.js/NestJS), the Helm dashboard (React Router/Vite), and the Portal. One package, three consumers, two module systems. That last part is where the trouble starts.

The setup

The contracts package is configured as a proper ESM package:

{
  "name": "@packages/contracts",
  "type": "module",
  "main": "dist/index.js"
}

Its tsconfig.json uses moduleResolution: "NodeNext", which requires explicit .js extensions on all relative imports — even though the source files are .ts:

// packages/contracts/src/index.ts
export * from './audit.contract.js';
export * from './health.contract.js';
export * from './connection.contract.js';
export * from './entities.contract.js';

This is correct TypeScript. When tsc compiles this, the .js extensions pass through verbatim into dist/index.js. Node.js ESM then resolves them to the compiled .js files in dist/. The runtime works.

The tests do not.

The first break: Jest can’t find the module

The Tunnel uses Jest with ts-jest for testing. When hatchet.worker.spec.ts imported a workflow that transitively imported @packages/contracts, Jest blew up:

Cannot find module '@packages/contracts' from 'src/db/audit.service.ts'

This was the simple problem. The Tunnel’s tsconfig.json had a paths alias for @packages/contracts, but Jest’s moduleNameMapper didn’t. Jest doesn’t read tsconfig.paths — you have to mirror them explicitly:

{
  "moduleNameMapper": {
    "^@packages/contracts(|/.*)$": "<rootDir>/../../packages/contracts/src/$1"
  }
}

This tells Jest: when you see @packages/contracts, resolve it to the TypeScript source in the monorepo, not the compiled dist/. That’s the right thing to do — ts-jest transforms .ts files on the fly, so we want it reading source directly.

The second break: .js extensions in source

Adding the mapper fixed the module lookup, but surfaced the next error:

Cannot find module './audit.contract.js' from '../../packages/contracts/src/index.ts'

Jest is now reading index.ts from source. That file says export * from './audit.contract.js'. Jest looks for audit.contract.js on disk. It doesn’t exist — only audit.contract.ts does. The .js extension is a convention for the compiled output, but Jest is reading source and doesn’t know to strip it.

The wrong fix

Our first instinct was to remove the .js extensions from the source:

export * from './audit.contract';
export * from './health.contract';

And switch tsconfig.json from NodeNext to bundler moduleResolution, which allows extensionless imports. The tests passed. The tsc build passed. We committed.

Then we ran the app:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  '/packages/contracts/dist/audit.contract'
  imported from '/packages/contracts/dist/index.js'

Node.js ESM at runtime requires explicit extensions. The tsc build had faithfully copied our extensionless imports into dist/index.js, and Node.js rejected them. We’d fixed the tests and broken production.

The actual fix

The .js extensions in source are correct and must stay. They’re not a hack — they’re the TypeScript-endorsed way to write ESM-compatible code under NodeNext resolution. The file on disk is .ts, but the import specifier refers to the .js file that will exist after compilation.

The Jest side needs a single moduleNameMapper entry that strips .js from relative imports during test resolution:

{
  "moduleNameMapper": {
    "^@packages/contracts(|/.*)$": "<rootDir>/../../packages/contracts/src/$1",
    "^(\\.{1,2}/.+)\\.js$": "$1"
  }
}

The pattern ^(\\.{1,2}/.+)\\.js$ matches any relative import ending in .js (like ./audit.contract.js or ../connection.contract.js) and rewrites it without the extension (./audit.contract). Jest’s resolver then finds the .ts file naturally.

This is the documented ts-jest approach for testing NodeNext packages, and the .js extension requirement itself is mandated by the TypeScript handbook for NodeNext resolution. It works because:

  • Runtime (Node.js ESM): reads compiled dist/index.js → sees ./audit.contract.js → finds dist/audit.contract.js
  • Bundlers (Vite, webpack): resolve .js to .ts automatically — they don’t care ✓
  • Jest (ts-jest): the mapper strips .js → resolver finds .ts source → ts-jest transforms it ✓

The third layer: pnpm and ESM transitive deps

While fixing the contracts issue, we uncovered a second Jest problem. The hatchet.worker.spec.ts transitively imports axios-cookiejar-support, which ships ESM-only JavaScript. Jest runs in CommonJS mode and choked:

SyntaxError: Cannot use import statement outside a module

The Tunnel already had a transformIgnorePatterns config to tell Jest “transform these ESM packages instead of ignoring them”:

{
  "transformIgnorePatterns": [
    "node_modules/(?!(@faker-js|axios-cookiejar-support|http-cookie-agent|tough-cookie)/)"
  ]
}

This worked with npm and yarn, but not pnpm. In a pnpm workspace, node_modules is structured as:

node_modules/.pnpm/axios-cookiejar-support@6.0.5/node_modules/axios-cookiejar-support/dist/index.js

Jest’s regex matched at the outer node_modules/.pnpm/ level — the next path segment is .pnpm, not axios-cookiejar-support, so the exclusion list never activates. The fix is a negative lookahead that skips the .pnpm directory:

{
  "transformIgnorePatterns": [
    "node_modules/(?!\\.pnpm)(?!(axios-cookiejar-support|http-cookie-agent|tough-cookie)/)"
  ]
}

Now the regex skips node_modules/.pnpm/, falls through to the inner node_modules/axios-cookiejar-support/, and correctly transforms it.

What we learned

Shared packages in a TypeScript monorepo sit at the intersection of three module resolution strategies, and each one has different rules:

ConsumerResolutionExtension requirement
Node.js ESM (runtime)Explicit .js required./foo.js
Vite / webpack (bundler)Extensionless OK./foo or ./foo.js
Jest + ts-jest (test)Extensionless on .ts source./foo

The only import style that works for all three is .js extensions in source with a Jest mapper to strip them during tests. Fighting this — by removing extensions or switching moduleResolution — fixes one consumer and breaks another.

The contracts package now builds cleanly, the Tunnel tests pass (7 suites, 44 tests), the app starts without ERR_MODULE_NOT_FOUND, and the Helm dashboard resolves the same schemas through Vite without any changes.

Sometimes the “hack” is the architecture.


This is the second in our engineering series. Previously: Introducing the QuickBridge Control Plane.