Configuring TypeScript Path Aliases in a DevContainer

@app/* type-checks green in the editor but throws Cannot find module at build — the classic path-alias trap. This page explains why (tsconfig paths only teach the type-checker) and fixes it by mirroring the aliases in your bundler or Node resolver so the editor and the build finally agree.

The reason this matters inside a devcontainer specifically is that the container is meant to be the single source of truth for how the project compiles, bundles, and runs. When an alias resolves in one tool but not another, you get a build that passes on one machine and fails in CI or a teammate's freshly rebuilt container — exactly the class of drift a devcontainer is supposed to eliminate. Aliases like @app/* are attractive because they flatten deep relative imports (../../../lib/db) into stable, refactor-proof names, but that convenience only holds if every layer that touches your source agrees on what @app points to. Treat the alias map as configuration that has to be replicated, not a single setting that magically propagates.

Reach for this fix the moment you see a green editor and a red terminal for the same import — that split is the tell. The mental model to hold onto is that tsconfig.json paths is a type-only instruction: it shapes what the language server and tsc --noEmit believe about your imports, and nothing else. The bundler, the Node runtime, and the test runner each maintain their own independent module-resolution logic, and none of them read paths to rewrite an import to a real file on disk. So the whole task reduces to one repeated action: take the mapping you wrote once for the type-checker and mirror it, verbatim, into every other resolver that processes your code.

Prerequisites

You need a TypeScript project with path aliases and a bundler or runtime resolver.

  • tsconfig.json with baseUrl and paths defined.
  • A bundler (Vite, webpack, esbuild) or a runtime resolver.
  • The build tool's config you can edit.

Alias prerequisitestsconfig paths teach only the type-checker; the bundler or runtime needs the same map.tsconfig pathstype-checker onlyBundler/runtimeneeds the same mapMirroralign resolversVerifyeditor + build

The prerequisite that trips people up is the relationship between baseUrl and paths. Every entry in paths is resolved relative to baseUrl, so a mapping like "@app/*": ["src/*"] only means src when baseUrl is the project root ("."). If baseUrl points elsewhere, or is omitted on an older TypeScript version that still requires it, the same paths block silently resolves to a different directory and your aliases miss or land on the wrong files. Before you touch any bundler config, confirm that baseUrl and paths together describe the exact directory you intend, because every mirror you write later copies that assumption downstream.

It also helps to know which of the two resolver strategies you actually need before you start. If your code is bundled — Vite, webpack, or esbuild — the alias belongs in the bundler and the emitted output already contains real paths, so there is nothing left to resolve at runtime. If instead you run compiled JavaScript directly under Node, the runtime is the tool that has to learn the mapping, through a loader like tsconfig-paths or by rewriting the aliases to relative paths during compilation. Deciding this up front stops you from configuring a bundler you do not ship and forgetting the runtime you do.

Step-by-Step Implementation

  1. Define the aliases in tsconfig for the type-checker.
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "@app/*": ["src/*"] }
  }
}

This block is what makes import { db } from '@app/db' type-check without a red squiggle. The trailing /* on both sides of "@app/*": ["src/*"] is doing real work: it is a wildcard capture, so @app/db maps to src/db, @app/lib/logger to src/lib/logger, and so on. Drop the /* and you declare a single non-wildcard alias that only matches the bare specifier @app, which is almost never what you want. Because baseUrl is ".", src/* is anchored at the project root, and the language server now has everything it needs for autocomplete and go-to-definition. What it deliberately does not do is change a byte of emitted output — this is purely the type-checker's map, which is exactly why the next steps exist.

  1. Mirror them in the bundler so runtime resolution matches (Vite example).
// vite.config.ts
export default { resolve: { alias: { '@app': '/src' } } };

Here the same mapping is restated in the language the bundler understands. Vite resolves resolve.alias against the project root, and the leading slash in '/src' means the project-root src directory rather than the filesystem root — a detail that catches people who copy a bare 'src' and watch the alias resolve against the wrong base. Note the shape difference from tsconfig: the bundler entry is prefix-based ('@app' -> '/src'), so it handles @app/db and everything beneath it without a /* wildcard. This is the layer that fixes the Cannot find module thrown by npm run build, because Vite's resolver — not tsconfig — is what turns @app/db into a real file when it walks the module graph. Keeping the target identical to the tsconfig target is what prevents the two resolvers from silently disagreeing.

  1. Or use a resolver plugin (e.g. tsconfig-paths) for Node runtime.
node -r tsconfig-paths/register dist/index.js

When there is no bundler in the picture and you launch compiled output straight through Node, this is the layer that supplies the mapping. The -r tsconfig-paths/register flag preloads a hook before your entry file runs, and that hook reads your tsconfig paths and patches Node's module resolution so require('@app/db') finds src/db at runtime. Without it, Node has no concept of @app and throws Cannot find module '@app/db' the instant it evaluates the import — the runtime cousin of the build failure. The important caveat is that tsconfig-paths resolves against the source layout in tsconfig, so if your compiled files land in dist with a different shape, either point the resolver at the right baseUrl for the emitted tree or compile the aliases away so the shipped JavaScript carries plain relative paths and needs no runtime hook.

  1. Verify the alias resolves in both type-check and build.
npx tsc --noEmit && npm run build

The && is deliberate: it runs the two resolvers that most often disagree, in sequence, and reports success only if both pass. tsc --noEmit exercises the type-checker's view without producing output, so it catches a broken paths entry; npm run build then drives the bundler, exercising the mirror you added in step two. Running them together turns the split-brain symptom into a single green or red signal — if the first passes and the second fails, the gap is in the bundler layer, not tsconfig. Wire this pairing into your devcontainer's CI check or a postCreateCommand so a mismatched alias map fails loudly at build time rather than surfacing as a mysterious runtime error days later.

Alias resolution layersAliases must be mirrored across the type-checker, bundler, and runtime to resolve everywhere.tsconfig pathstype-checker resolutionbundler aliasbuild-time resolutionresolver pluginruntime resolutionalignededitor == build == runtime

Common Pitfalls

Alias failures are a tsconfig-only mapping the bundler or runtime never learned.

The most common pitfall is treating the four resolvers as one. You add @app to tsconfig, everything looks fine in the editor, and you assume the job is done — but the bundler, the Node runtime, and the test runner each kept their own resolution logic and never saw your change. The failure then surfaces at whichever layer you exercise next, which is why the same alias can pass tsc on Monday and break Jest on Tuesday. The remedy is to keep a single canonical alias list and mechanically apply it to every tool: tsconfig paths, the bundler alias, the runtime resolver, and the test runner's moduleNameMapper. Better still, generate the bundler and Jest maps from the tsconfig paths so there is genuinely one source of truth and the resolvers cannot drift apart.

A second, quieter pitfall is inconsistent base directories. Because tsconfig resolves paths against baseUrl while the bundler resolves its alias against the project root and tsconfig-paths resolves against the emitted tree, it is possible for all three to "have" the alias yet point at three subtly different folders. This shows up as an alias that works for most imports but fails near the edges of your directory layout, or one that resolves to stale compiled files. When an alias resolves inconsistently rather than failing outright, suspect the base directory first, and confirm that src/*, /src, and the runtime baseUrl all describe the same physical path inside the container.

Alias triageA triage path from an editor-only alias to one that resolves at build and runtime.Does @app resolve in the editor?YESBut fails at build?Is the alias mirrored in the bundler?NOAdd the alias to the resolverResolves everywhere

SymptomRoot CauseRemediation
@app works in editor, fails at buildBundler doesn't know the aliasMirror paths in the bundler config
Fails at runtime under NodeNo runtime resolver for aliasesUse tsconfig-paths or compile the alias away
Alias resolves inconsistentlybaseUrl mismatchAlign baseUrl across tools
Jest can't resolve @appTest runner unaware of pathsAdd moduleNameMapper for the alias

Conclusion

tsconfig paths are for the type-checker only — they do not rewrite imports at build or runtime. Mirror the same mapping in your bundler (or a runtime resolver) so @app/* resolves everywhere the code is processed. When the type-checker, the bundler, and the runtime share one alias map, the editor's green checkmark finally means the build will pass.

The strategic payoff is that a correctly mirrored alias map turns a source of environment-specific surprises into something reproducible. Inside a devcontainer, the whole point is that a rebuilt container matches the last one and a colleague's; an alias that lives only in tsconfig quietly breaks that promise the moment the code leaves the editor. By pinning the mapping into every resolver and running tsc --noEmit && npm run build as a gate, you make the alias behavior a property of the committed configuration rather than of whichever tool ran first. That is the same discipline behind pinning image digests and caching dependencies: state the intended resolution once, where the container reads it deterministically, and remove the chance for tools to improvise.

Seen that way, path aliases are a small instance of the broader reproducibility theme this workspace guide keeps returning to. The failure mode — green here, red there — is drift between two views of the same code, and the fix is convergence on a single declared map. Do that and @app/* stops being a trap and becomes exactly what it was meant to be: a stable, refactor-proof name that means the same thing in the editor, in the build, at runtime, and in the tests.

Type-check vs runtimetsconfig handles the editor; the bundler, runtime, and tests need the same alias map.tsconfig givesEditor resolutionType-checkingGo-to-definitionAlso mirror inBundler aliasRuntime resolverTest runner

FAQ

Why do my path aliases work in the editor but fail at build? Because tsconfig.json paths only tell the type-checker how @app/* maps to a directory — they don't rewrite the actual import at build or runtime. The bundler or Node resolver doesn't know the mapping, so it throws Cannot find module. Mirror the alias in your bundler config to fix it. The editor feels authoritative because the language server and tsc --noEmit both read paths directly, so anything that satisfies the type-checker looks correct there. But that green state proves nothing about the build, which walks the module graph through a completely separate resolver. Until the bundler has its own copy of the mapping, the two will keep disagreeing no matter how clean the editor looks.

How do I make aliases work when running under Node directly? Use a runtime resolver such as tsconfig-paths (node -r tsconfig-paths/register), or compile the aliases away so the emitted JavaScript uses real relative paths. Either way, the runtime needs the mapping that tsconfig only gave the type-checker. The -r flag preloads the resolver hook before your entry file executes, which is why it has to appear on the node invocation itself rather than somewhere inside your code. If you would rather not carry a runtime dependency into production, the compile-away approach is cleaner: a step that rewrites @app/db to a relative path during the build leaves you with plain JavaScript that any Node version resolves without help, which is usually the better fit for a shipped container image.

Why can't my test runner resolve the alias? Test runners resolve modules independently of tsconfig. In Jest, add a moduleNameMapper that mirrors your paths; other runners have equivalent settings. The rule is universal: every tool that resolves modules — editor, bundler, runtime, tests — needs the same alias map. Jest is a frequent offender because it runs through its own transform and resolver stack that never consults tsconfig, so a suite that imports through @app will fail with Cannot find module even when the app builds fine. Keep the moduleNameMapper regex aligned with the same src target the other tools use, and regenerate it from tsconfig if you can, so a new alias does not silently break tests while passing the build.

Does the devcontainer itself need any special configuration for path aliases? Not directly — path aliases are resolved by TypeScript, the bundler, the runtime, and the test runner, none of which are container features. What the devcontainer gives you is a fixed place to enforce the mapping. Running the tsc --noEmit && npm run build gate as a postCreateCommand or in the container's CI job means a mismatched alias map fails at container build time, where it is cheap to notice. The container's contribution is determinism, not resolution.

Should I add aliases per-package or once at the workspace root in a monorepo? Prefer a shared base the packages extend, but be aware that paths do not compose the way you might hope. A child tsconfig.json that sets its own baseUrl re-anchors every inherited paths entry against the child's directory, a common cause of aliases that resolve in one package and miss in another. Define the alias in the tsconfig whose baseUrl makes the target correct, and mirror it into each package's bundler and test config the same way you would in a single project — the monorepo does not change the rule that every resolver needs its own copy.