Skip to content

Worked review 4: a library's tsconfig and package.json

The first three reviews were programs. This one is config — the tsconfig.json and package.json of a tiny library, wr4-lib, that exports one Logger class. Config bugs don’t show up as a wrong console output; they show up as a build that refuses to run, or a consumer whose require() call fails for a reason nothing in the source code explains. Every failure below is quoted from a real run in this course’s scratch environment — tsc -p, npm pack + @arethetypeswrong/cli, and an actual consumer calling both require() and import().

The source, tsconfig.json, and package.json

Section titled “The source, tsconfig.json, and package.json”
export enum LogLevel { Info, Warn, Error }
export class Logger {
constructor(public readonly level: LogLevel) {}
log(message: string): void {
console.log(`[${LogLevel[this.level]}] ${message}`);
}
}
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"declaration": true,
"outDir": "dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}
{
"name": "wr4-lib",
"version": "1.0.0",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
}
}

None of these three files individually looks obviously wrong — this is what a config review actually feels like. Try to name all four bugs before opening the answers.

Bug 1 — tsconfig.json has no rootDir

Symptom: tsc -p tsconfig.json refuses to build at all.

Real output:

tsconfig.json(7,5): error TS5011: The common source directory of 'tsconfig.json' is
'./src'. The 'rootDir' setting must be explicitly set to this or another path to
adjust your output's file layout.
Visit https://aka.ms/ts6 for migration information.

Evidence: with outDir set but no rootDir, tsc cannot decide how to mirror src/index.ts into dist/ without guessing — and TypeScript 7 turned that guess into a hard error instead of the silent dist/src/index.js layout older versions produced.

Fix: set rootDir explicitly:

{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"rootDir": "src",
"declaration": true,
"outDir": "dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}
Bug 2 — "main": "./src/index.ts" points at raw source

Symptom: the package’s main field — the classic entry point every pre-exports tool still reads — points directly at TypeScript source, not a built file.

Evidence: modern Node ignores main entirely once exports is present, so this specific package still resolves correctly for import/require today — but any tool that resolves the classic way (an older bundler, a monorepo task runner that reads main directly, TypeScript’s own moduleResolution: "node10" for a consumer project) would be handed a .ts file no JavaScript runtime can execute directly. Confirmed directly: running that exact file with Node’s own TypeScript support fails immediately (see Bug 4) — precisely the failure a stray main field this pointed can hand to the wrong tool.

Fix: point main at the actual build output:

"main": "./dist/index.js"
Bug 3 — exports has no require condition, but main implies one

Symptom: a main field has historically meant “this is how CommonJS consumers load this package” — but this package’s exports map offers no require condition at all, only import.

Real output (an actual consumer package, in scratch, calling both):

$ node -e "require('wr4-lib')"
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: No "exports" main defined in
.../node_modules/wr4-lib/package.json
$ node --input-type=module -e "import('wr4-lib').then(m => new m.Logger(m.LogLevel.Warn).log('hi'))"
[Warn] hi

require() fails outright; dynamic import() works. @arethetypeswrong/cli --pack confirms the same gap from the package’s own published shape:

⚠️ A require call resolved to an ESM JavaScript file, which is an error in Node and
some bundlers. CommonJS consumers will need to use a dynamic import.
┌───────────────────┬──────────────────────────────┐
│ node10 │ 🟢 │
├───────────────────┼──────────────────────────────┤
│ node16 (from CJS) │ ⚠️ ESM (dynamic import only) │
├───────────────────┼──────────────────────────────┤
│ node16 (from ESM) │ 🟢 (ESM) │
├───────────────────┼──────────────────────────────┤
│ bundler │ 🟢 │
└───────────────────┴──────────────────────────────┘

Fix: this package genuinely is ESM-only. The fix is not to fake CJS support with no build for it — that would need a real second build target (dist/index.cjs plus a .d.cts declaration, referenced from a "require" condition). The actual, honest fix here is to stop implying CJS support the package never had: once main correctly points at dist/index.js (Bug 2’s fix), the package is at least consistent about being ESM-only, and this warning becomes a documented, understood trade-off instead of an accidental side effect of a stray field — write it down in the README rather than leaving a consumer to discover it via a production crash. If real dual CJS/ESM support is required, the standard remedy is exactly the .d.mts/.d.cts split this course’s Build & Emit lesson covers.

Bug 4 — the source uses a numeric enum and a parameter property

Symptom: both compile perfectly under tsc --strict — this is exactly the erasable-syntax gap this course’s AI Bug Catalog covers (patterns 20 and 21).

Real output (node --experimental-strip-types src/index.ts, run directly on the source — exactly what Bug 2’s stray main field could hand a tool):

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

Fix: an as const union instead of enum, and an explicit field instead of a parameter property — both erasable, no emit required beyond ordinary object/class syntax:

export const LogLevel = { Info: "Info", Warn: "Warn", Error: "Error" } as const;
export type LogLevel = typeof LogLevel[keyof typeof LogLevel];
export class Logger {
readonly level: LogLevel;
constructor(level: LogLevel) {
this.level = level;
}
log(message: string): void {
console.log(`[${this.level}] ${message}`);
}
}
export const LogLevel = { Info: "Info", Warn: "Warn", Error: "Error" } as const;
export type LogLevel = typeof LogLevel[keyof typeof LogLevel];
export class Logger {
readonly level: LogLevel;
constructor(level: LogLevel) {
this.level = level;
}
log(message: string): void {
console.log(`[${this.level}] ${message}`);
}
}
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"rootDir": "src",
"declaration": true,
"outDir": "dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}
{
"name": "wr4-lib",
"version": "1.0.0",
"type": "module",
"types": "./dist/index.d.ts",
"main": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}

Proof, real output, in order:

$ tsc -p tsconfig.json
(no output -- exit 0)
$ node --experimental-strip-types src/index.ts
(no output -- parses and runs cleanly, no enum/parameter-property syntax left to reject)
$ node -e "import('wr4-lib').then(m => new m.Logger(m.LogLevel.Warn).log('all good'))"
[Warn] all good

tsc -p now builds without a single flag missing; the source itself is plain, erasable syntax that runs the same whether it goes through tsc, Node’s strip-types, or any other transform; and the package resolves and runs exactly as its exports map promises. The node16 (from CJS) warning from attw is unchanged — because this package genuinely, deliberately does not support require() — but it is no longer contradicted by a main field that implied otherwise.

BugToolReal signal
1. Missing rootDirtsc -p, directlyTS5011, refuses to build at all
2. main points at sourceNothing automatedOnly surfaces when some other tool actually tries to resolve via main
3. No require condition@arethetypeswrong/cli, directly; confirmed by a real require() callCJSResolvesToESM warning; ERR_PACKAGE_PATH_NOT_EXPORTED
4. enum / parameter propertyNothing via tsc --strict; only running the sourceERR_UNSUPPORTED_TYPESCRIPT_SYNTAX under node --experimental-strip-types

Only one of these four is a tsc error a build simply cannot ignore. The other three are exactly the kind of thing this module has trained you to check for by hand — or with attw, which exists specifically because nothing else in the ladder looks at a package from a consumer’s point of view.

tsc -p tsconfig.json fails immediately with TS5011 on the original config. What specifically is missing, and why does TypeScript 7 treat it as a hard error instead of guessing?
The package's exports map has only an 'import' condition, and require('wr4-lib') fails with ERR_PACKAGE_PATH_NOT_EXPORTED. Why does the presence of a 'main' field pointing at a real file not rescue the require() call?
Both the enum and the parameter property in the original source compile with zero tsc --strict errors, but node --experimental-strip-types src/index.ts fails immediately. What does this prove about the relationship between 'compiles' and 'runs' for this kind of syntax?
After the fix, attw --pack still shows the same 'node16 (from CJS): ESM (dynamic import only)' warning it showed before. Why is this now considered an acceptable, documented state rather than an unfixed bug?