skip to content

A tsconfig.json sets `"paths": { "@app/*": ["src/*"] }` and `tsc` compiles with no errors, but running the emitted output with `node dist/index.js` fails with "Cannot find module '@app/utils'". Why does the compiler accept a specifier the runtime rejects?

level: middleimportance: must knowfreq 68%

answer

  1. one side of the pipeline only
  2. resolution, never emit
  3. the string is copied verbatim
  4. the loader was never told
  5. declarations inherit the same problem

basics

~20 s

Because paths is a type-checker-only mapping. The compiler uses it to find declarations for the specifier, but it never rewrites import specifiers in the emitted JavaScript, so the output still says '@app/utils' and the runtime has no idea what that means.

solid answer

~40 s

`paths` tells the checker where to look when it sees a non-relative specifier — nothing more. During emit, `tsc` copies module specifiers through verbatim; it deliberately does not rewrite `@app/utils` into `./utils` or any relative path. So type checking succeeds against `src/utils.ts` while the emitted `require('@app/utils')` reaches a runtime that was never told about the alias. The fix always lives outside the compiler: the thing that loads the code has to resolve the same alias — a bundler's alias configuration, a runtime resolver hook, or a package.json subpath-imports entry, depending on how the code is executed. A useful mental model is that `paths` is a *promise* you make to the checker about how modules will be found, and like any promise the compiler trusts, it can be a lie.

code

json · 8 lines
json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/*": ["src/*"]
    }
  }
}

go deeper

for a junior

Recall that paths only helps the type checker find files, and that the emitted JavaScript still contains the alias exactly as you typed it.

for a middle

Explain why the compiler refuses to rewrite specifiers — the correct rewrite depends on the emit location, the module format and the loader — and name the second place the alias must be configured.

for a senior

Demonstrate that you treat a paths entry as an unverified claim: point out that it can disagree with the bundler alias, that it leaks into published declarations, and how you would catch that before release.

for a principal

Own the standard: decide whether aliases are allowed at all, since every one of them creates a second configuration that must be kept in sync, and weigh them against real package boundaries that tooling can enforce.

## What `paths` actually is `paths` lives in `compilerOptions` and maps specifier patterns to lists of locations the compiler should try: ```json { "compilerOptions": { "baseUrl": ".", "paths": { "@app/*": ["src/*"] } } } ``` When module resolution encounters the non-relative specifier `@app/utils`, this entry says: before falling back to the normal `node_modules` walk, try `src/utils` (with the usual extension and index-file substitutions). If a match is found, the checker types the import against that file. The mapping is consulted during **resolution**, which is a compile-time activity, and its whole effect is on what the checker believes. Entries may list several targets — `["src/*", "generated/*"]` — tried in order, which is how a project can prefer generated stubs with a source fallback. `paths` can also be given without `baseUrl`, in which case the targets resolve relative to the directory containing the tsconfig file. When `baseUrl` *is* set, it does something extra and slightly dangerous on its own: every non-relative specifier becomes resolvable against that directory, so a local folder named `src/utils` can shadow a real package named `utils`. ## Why the compiler does not rewrite the output This is the crux of the question, and it is a deliberate design decision rather than an oversight. `tsc` treats module specifiers as opaque strings it must not touch. Rewriting them correctly is impossible in general: the right output specifier depends on where the file is emitted, on whether the consumer is a bundler or a runtime resolver, on `exports` maps, on file extensions in ESM, and on configuration the compiler cannot see. Rather than guess, it emits what you wrote. So a source line `import { format } from '@app/utils'` becomes `require("@app/utils")` under CommonJS emit, or stays `from "@app/utils"` under ESM emit. The loader then applies its own algorithm, finds nothing named `@app`, and throws. ## The failure modes this produces - **Runtime module-not-found** — the case in the question. The build is green and the app is broken. - **Broken declaration output.** Emitted `.d.ts` files also keep the alias verbatim. If you publish a package whose declarations say `import { X } from '@app/utils'`, consumers get an unresolvable import, because they have no such `paths` entry. - **Silent divergence.** A `paths` entry may point at a file that will never exist at runtime, and nothing checks that claim. The alias can also disagree with the bundler's alias, so the checker types one file while the runtime loads another. ## Where the fix lives The compiler cannot fix this, so something in the execution path must learn the same mapping. In a bundled application, the bundler's own alias configuration is the counterpart, and keeping the two in sync is the ongoing cost. When the output is run directly by a runtime, either a resolver hook is installed ahead of the module loader, or the alias is expressed in a form the runtime itself understands — package.json subpath imports (the `#name` form) are the standardised mechanism for that. The details of each belong to the bundler and runtime topics; the point for a TypeScript interview is knowing that a second, independent configuration is mandatory and that `paths` alone is never sufficient. ## When `paths` is worth the cost It genuinely earns its place when the checker's view and the runtime's view are guaranteed to line up: a bundler-driven app whose alias config mirrors the tsconfig, or a monorepo where a workspace package name is mapped to its source so that editors jump to `.ts` files instead of built `.d.ts`. It is a poor fit for packages you publish, and it is a bad substitute for real package boundaries: an alias tells the checker where a file is, but it does not create a module boundary anyone can enforce. ## The one-sentence version `paths` changes resolution, not emit — and the runtime only ever sees the emit.

  • Does the alias problem also affect the .d.ts files the compiler emits?
    Yes, and it is the more insidious version. Declaration output preserves specifiers verbatim too, so a published package whose `.d.ts` says `from '@app/utils'` gives every consumer an unresolvable import — they have no matching `paths` entry. This is a strong argument for avoiding `paths` in anything you publish, or for rewriting specifiers as an explicit post-build step.
  • Is baseUrl required in order to use paths, and what does baseUrl do on its own?
    It is not required — without it, `paths` targets resolve relative to the tsconfig file's directory. On its own, `baseUrl` makes every non-relative specifier resolvable against that directory before the usual `node_modules` lookup, which is why a local `src/utils` folder can silently shadow an installed package named `utils`. Most projects want `paths` with explicit prefixes rather than a bare `baseUrl`.

saying these in an interview costs you the question

  • Thinks tsc rewrites aliases into relative paths on emit
  • Calls paths a runtime module alias
  • Assumes a green build proves the imports will resolve
  • Believes paths changes where files are emitted
  • Forgets that emitted .d.ts files keep the alias too

context