tsconfig in Depth: Paths, Emit, and Project References (Part 2)

Learn the tsconfig.json options that control path aliasing, compiled output (emit), multi-package project structure with project references, and how module resolution modes interact with package.json's exports/imports maps — with full example configurations for a Vite+React app and a Node application.

Part 2 covers the tsconfig options that matter most once a project grows past a single package — where files go when compiled, how large codebases split into independently-buildable pieces, and how TypeScript's module resolution algorithms map onto the real resolution rules Node and bundlers use in practice.

Path mapping: baseUrl and paths

baseUrl sets a base directory that non-relative import specifiers are resolved against, and paths maps specifier patterns to real locations relative to that base — together they let a project write import { Button } from "@/components/Button" instead of import { Button } from "../../../components/Button", which stays correct no matter how deeply nested the importing file is.

It's essential to understand that paths is a type-checking-time convenience only — tsc itself does not rewrite these import specifiers in its emitted JavaScript output. A bundler (Vite, webpack) or a separate tool (like the tsc-alias package, or Node's own path mapping in newer versions) has to perform the equivalent resolution at runtime or build time, or the aliased imports will fail to resolve when the code actually runs, even though tsc type-checked them successfully.

A common paths pattern maps a @/ prefix to the project's src directory: "paths": { "@/*": ["./src/*"] } alongside "baseUrl": ".". Keep the mapping's structure predictable and shallow — deeply nested or overly clever path aliases make it harder for both humans and tooling to trace an import back to its real file.

Emit control: rootDir, outDir, sourceMap, declaration(Map), incremental, skipLibCheck

rootDir tells TypeScript the common root of all your input files, which determines the folder structure of the compiled output; outDir is where that compiled output actually goes. Together, src/app.ts with rootDir: "./src" and outDir: "./dist" compiles to dist/app.js, preserving the relative structure under src.

sourceMap emits .js.map files alongside compiled .js, letting debuggers and error stack traces point back to the original .ts source lines instead of the generated JavaScript. declaration emits the paired .d.ts files described in the Declaration Files lesson; declarationMap additionally emits a source map for those declarations, so 'go to definition' in an editor can jump straight to the original .ts source rather than the generated .d.ts.

incremental makes TypeScript save information about the previous compilation (in a .tsbuildinfo file) so subsequent compiles only re-check what actually changed, substantially speeding up repeated builds in large projects. skipLibCheck skips type-checking of .d.ts files (including ones inside node_modules) — it's almost always turned on in practice, because it avoids wasted time re-validating third-party type definitions that are outside your control anyway, and sidesteps occasional conflicts between two packages' bundled type definitions.

include/exclude, project references, and composite projects

include and exclude are glob-pattern arrays controlling which files belong to the program at all — include: ["src/**/*"] and exclude: ["node_modules", "dist"] are typical. Files outside include (or matched by exclude) are invisible to this particular tsconfig's compilation, even if they'd otherwise type-check fine.

Project references let a large codebase be split into multiple tsconfig.json files, each representing an independently buildable unit (a shared packages/ui library, a packages/api backend, an apps/web frontend), wired together with a references array pointing at each dependency's tsconfig. Building with tsc --build compiles referenced projects in dependency order automatically, only rebuilding what actually changed.

A project referenced by another must set "composite": true, which forces a few things: declaration is implied (since dependents need the .d.ts output to type-check against), rootDir becomes stricter about not including files outside itself, and the project must be explicitly buildable on its own. This structure is what lets a monorepo's tsc --build incrementally rebuild only the packages that actually changed, rather than re-checking the entire codebase on every change.

Module resolution modes and package.json exports/imports/typesVersions

moduleResolution has evolved through several modes reflecting how Node.js resolution itself has evolved: "node10" (formerly "node") is the classic CommonJS-era algorithm; "node16"/"nodenext" implement Node's actual modern ESM-aware resolution, which respects each package's package.json "type" field and exports map, and cares whether an import specifier has a file extension; "bundler" mimics how modern bundlers (Vite, esbuild, webpack) resolve imports, which is more permissive than Node's own rules since a bundler doesn't have Node's runtime module-loading constraints.

A package.json's exports field explicitly declares which of its files are importable at all, and can map different files to different conditions — "import" versus "require", or a dedicated "types" condition pointing at the right .d.ts for a given resolution mode. Under "node16"/"nodenext" resolution, TypeScript actually respects this map, meaning a package can legally block certain deep imports or provide different types for ESM versus CommonJS consumers of the same package — this is a real behavioral difference from older, more permissive resolution modes.

A package.json's imports field (distinct from exports) defines internal-only path aliases (conventionally prefixed with #, e.g. #utils) usable within that package's own source, resolved the same way regardless of who's importing the package from outside. typesVersions lets a package ship different .d.ts files for different consumer TypeScript versions — useful when a package wants to use newer type features for consumers on a recent TypeScript while still providing a compatible (if less precise) type for consumers on an older version.

types and typeRoots control which ambient @types packages get included automatically — by default TypeScript pulls in every @types/* package found in node_modules, but types: ["node"] restricts that to only the listed packages, useful for preventing an unrelated @types package (say, one meant for a browser environment) from polluting a Node-only project's global scope. noEmit compiles for type-checking only and produces no output files at all (common when a separate bundler does the actual JS emit); emitDeclarationOnly emits .d.ts files but no .js, useful when a bundler handles JS output but you still want tsc to produce the type declarations for a published package.

isolatedDeclarations and stricter emit-adjacent settings

isolatedDeclarations requires every exported value to have an explicit, unambiguous type annotation, so a .d.ts can be generated for each file in complete isolation, without needing to analyze the rest of the program — this enables much faster declaration-only emit tools and is increasingly relevant for very large libraries and monorepo build pipelines. Newer TypeScript releases have also introduced narrower restrictions like erasableSyntaxOnly, which forbids TypeScript syntax that has no direct JavaScript equivalent (such as enums or parameter properties) specifically for codebases that want their .ts files to be runnable by a JavaScript engine's native type-stripping support with zero transformation.

{
  "compilerOptions": {
    "declaration": true,
    "isolatedDeclarations": true,
    "verbatimModuleSyntax": true
  }
}

Code Examples

moduleResolution: "bundler" matches how Vite actually resolves imports (rather than emulating Node's runtime rules), noEmit: true reflects that Vite's own toolchain (esbuild/Rollup) does the real JS compilation while tsc is only used for type-checking, and paths combined with Vite's own matching resolve.alias config makes @/* imports work both at type-check time and at build/runtime.

"moduleResolution": "bundler", "noEmit": true packages/shared is composite and declares its own declaration output so packages/api can type-check against it without recompiling its source; packages/api's references array tells tsc --build to compile shared first, in dependency order, and only rebuild api when its own files (or shared's output) actually change.

Common mistake: Forgetting "composite": true on packages/shared would prevent it from being referenced at all — TypeScript requires an explicit composite marker on any project that's meant to be built as a dependency of another.

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",

    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,

    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },

    "esModuleInterop": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "resolveJsonModule": true,

    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
// packages/shared/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true
  },
  "include": ["src"]
}

// packages/api/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "composite": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "skipLibCheck": true,
    "incremental": true
  },
  "references": [
    { "path": "../shared" }
  ],
  "include": ["src"]
}

Project references build order

Project references turn a monorepo's build into a dependency graph tsc understands directly, rather than a hand-maintained script that rebuilds everything on every change.

packages/shared (composite)
   |  builds first, emits dist/ + .d.ts
   v
packages/api  --references--> packages/shared
   |  type-checks against shared's .d.ts, then builds
   v
apps/web      --references--> packages/api, packages/shared

tsc --build apps/web   compiles the whole chain in dependency order,
                        skipping any project whose inputs haven't changed

Important Rules

Remember that paths/baseUrl aliases are type-checking-time only — your bundler or runtime needs its own matching alias configuration or the emitted code's imports will fail to resolve.

Set moduleResolution to match your actual toolchain: "bundler" for Vite/webpack/esbuild-based apps, "NodeNext" for Node applications and published packages that care about correct ESM/CJS interop.

Mark any tsconfig meant to be referenced by another project with "composite": true, and keep its rootDir/include scoped tightly to that package's own source.

Use skipLibCheck: true in virtually every project — re-checking third-party .d.ts files rarely catches real bugs in your own code and can slow builds or surface irrelevant conflicts.

When a bundler handles the actual JavaScript output, set noEmit: true (or emitDeclarationOnly: true if you still need .d.ts files) so tsc is used purely for type-checking, not for producing files nothing consumes.

Where this shows up

Any TypeScript monorepo — a design-system package consumed by several apps, a shared types package between a frontend and backend — relies on project references to keep incremental builds fast and dependency boundaries explicit. Meanwhile, moduleResolution: "bundler" versus "NodeNext" is one of the most common sources of confusing, environment-specific type errors when a team copies a tsconfig from a different kind of project (a Vite app's config into a Node CLI tool, or vice versa) without adjusting it for the actual runtime and toolchain.

// tsconfig has "paths": { "@/*": ["./src/*"] } but the bundler has no matching alias config

// tsconfig paths AND vite.config.ts's resolve.alias (or webpack's resolve.alias) both define "@/*" -> "./src/*" tsc only uses paths for type-checking and does not rewrite import specifiers in its output; without a matching alias configuration in whatever actually bundles or runs the code, an import from "@/components/Button" will type-check successfully but fail to resolve at runtime.

// packages/api references packages/shared, but shared's tsconfig has no "composite": true

// packages/shared/tsconfig.json includes "composite": true, "declaration": true

A project must explicitly opt in with composite: true to be legally referenced by another project's tsconfig — without it, tsc --build will error, since TypeScript needs the composite guarantees (declaration output, stricter rootDir) to build the dependency graph correctly.

[multiple-choice] Why do paths aliases defined in tsconfig.json sometimes fail to work at runtime even though tsc type-checks them successfully? A. paths aliases are always broken and should never be used B. tsc only uses paths for type-checking and does not rewrite the alias in emitted JavaScript — a bundler or runtime needs its own matching alias configuration C. paths only work inside .d.ts files, never in regular .ts files D. paths requires moduleResolution to be set to "node10"

B. tsc only uses paths for type-checking and does not rewrite the alias in emitted JavaScript — a bundler or runtime needs its own matching alias configuration — paths/baseUrl are purely a type-checking convenience; the emitted JavaScript still contains the original, unresolved import specifier, so whatever actually runs or bundles the code needs its own alias resolution configured to match, or the import will fail to resolve at runtime.

[true-false] A tsconfig.json project must set "composite": true if another project's tsconfig references it via the "references" array. A. True B. False

A. True — TypeScript requires composite: true on any project meant to be referenced by another, since referencing relies on guarantees composite mode provides (declaration output for the dependent to check against, a well-defined rootDir) that tsc --build depends on.

[multiple-choice] Which moduleResolution setting most closely matches how a tool like Vite or esbuild actually resolves imports, as opposed to Node's own runtime resolution rules? A. "classic" B. "node10" C. "bundler" D. "nodenext"

C. "bundler" — "bundler" moduleResolution is specifically designed to mimic how modern bundlers resolve module specifiers, which is more permissive than Node's own ESM-aware resolution rules (used by "node16"/"nodenext") since a bundler isn't bound by Node's runtime module-loading constraints.

Write a tsconfig.json for a publishable npm library (not an app) that: targets ES2020, uses NodeNext module and moduleResolution, emits both JavaScript and declaration files (with declarationMap) into ./dist from a ./src rootDir, and skips lib checking.

{ "compilerOptions": { "target": "ES2020", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", "rootDir": "./src", "declaration": true, "declarationMap": true, "skipLibCheck": true, "strict": true }, "include": ["src"] }

You'll need module, moduleResolution, target, outDir, rootDir, declaration, declarationMap, and skipLibCheck all set explicitly.