tsc --noEmit is clean but the app crashes on startup with a circular import
A service builds fine and the type-check is completely clean, yet the process dies the moment it boots. entity.ts declares the base class, order.ts extends it, and order-service.ts sits between them. Nobody has changed the types — only the import graph.
Explain why the type checker stays silent about a cycle that is fatal at run time, name what the emitted CommonJS is actually doing at the extends clause, and say how you would break the cycle without moving any type declarations into a shared dumping ground.
$ node dist/index.js
/app/dist/order.js:4
class Order extends entity_1.Entity {
^
TypeError: Class extends value undefined is not a constructor or null
$ npx madge --circular src
1) src/entity.ts > src/order-service.ts > src/order.ts > src/entity.ts
$ npx tsc --noEmit
(no errors)
Diagnose the cause.
Types resolve lazily, so a circular type reference is legal and the check stays silent. The value cycle in the emit is invisible to it: the module evaluated second gets the other's empty exports, so entity_1.Entity is undefined at extends. Erase the type-only edges with import type, and move shared values to a third module.
- ✗Expecting
tscto report a cycle it is legally allowed to resolve lazily - ✗Reordering imports in the entry file, which only moves which module gets the empty
exports - ✗Dumping every shared type into one
types.tsinstead of erasing the type-only edges
- →Why does
verbatimModuleSyntaxmake this class of bug appear earlier rather than later? - →When is a value cycle genuinely unavoidable, and how do you make it safe?
Why the type check is silent
The checker resolves types lazily: to learn the shape of Entity it never has to "run" entity.ts. A circular type reference is perfectly legal to it — and no diagnostic is produced.
The runtime works differently. The emitted CommonJS evaluates modules eagerly, in order, and the cycle looks like this:
index.js → require('order-service')
→ require('order')
→ require('entity') // entity starts evaluating
→ require('order-service') // ALREADY in progress → hands back an empty exports
What happens at extends
While a module is still evaluating, its module.exports is filled in gradually. Whichever module enters the cycle second receives the other's placeholder object, with no properties assigned yet. So entity_1.Entity is undefined, and class Order extends undefined throws.
The fix
Every edge in this cycle is a type edge — which means it can be erased:
// order-service.ts
import type { Entity } from './entity'; // erased — the runtime edge is gone
export function describe(e: Entity): string {
return e.id;
}
The rule: any edge that exists only for a type is written import type. Whatever value cycle is left after that (if any) is a genuine design problem — extract the shared value into a third module that both sides import, and the cycle becomes a tree.