Alias Contract in TypeScript
TypeScript must know where to locate modules during static analysis. This configuration does not affect output files but enables IDE navigation, autocomplete, and type-checking.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"baseUrl": ".",
"paths": {
"@infra/*": ["src/infrastructure/*"],
"@shared/*": ["src/shared/*"],
"@app/*": ["src/*"]
},
"forceConsistentCasingInFileNames": true,
"strict": true
},
"include": ["src/**/*.ts"]
}
Rationale: baseUrl establishes the root for relative path resolution. paths maps alias patterns to physical directories. forceConsistentCasingInFileNames is critical: it forces the compiler to reject case mismatches, catching macOS/Linux drift before it reaches CI.
Step 2: Mirror Configuration in the Bundler
Bundlers perform their own resolution pass. They do not read tsconfig.json paths natively. You must explicitly map aliases to filesystem paths.
// vite.config.ts
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@infra': path.resolve(__dirname, 'src/infrastructure'),
'@shared': path.resolve(__dirname, 'src/shared'),
'@app': path.resolve(__dirname, 'src')
}
},
build: {
outDir: 'dist',
sourcemap: true
}
});
Rationale: path.resolve ensures absolute paths, preventing ambiguity when the bundler processes nested entry points. Using vite.config.ts instead of vite.config.js allows TypeScript validation of the configuration itself.
Step 3: Synchronize the Test Runner
Test runners isolate modules and often use their own resolution algorithms. Jest and Vitest require explicit mapper configuration.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node'
},
resolve: {
alias: {
'@infra': path.resolve(__dirname, 'src/infrastructure'),
'@shared': path.resolve(__dirname, 'src/shared'),
'@app': path.resolve(__dirname, 'src')
}
}
});
Rationale: Vitest inherits Vite's resolver by default, but explicitly declaring aliases prevents drift when test-specific plugins or transformers are introduced. For Jest, the equivalent uses moduleNameMapper with regex patterns.
Step 4: Handle Runtime Execution
Node.js does not understand @infra or @shared. When running compiled output, you have two production-safe options:
Option A: Build-Time Transformation (Recommended)
Configure the bundler to emit standard relative paths or preserve aliases but resolve them at runtime using a lightweight loader.
// package.json
{
"type": "module",
"scripts": {
"build": "vite build",
"start": "node --loader ts-node/esm src/main.ts"
}
}
Option B: Runtime Path Polyfill
Use tsconfig-paths or module-alias to intercept Node's Module._resolveFilename hook. This is useful for direct TypeScript execution but adds overhead.
// src/runtime-resolver.ts
import { register } from 'tsconfig-paths';
import path from 'path';
register({
baseUrl: path.resolve(__dirname, '..'),
configFile: path.resolve(__dirname, '../tsconfig.json')
});
Rationale: Build-time transformation is preferred for production because it eliminates runtime resolution overhead and ensures tree-shaking works correctly. Runtime polyfills are acceptable for development scripts or legacy CJS migrations.
Step 5: Enforce Package Boundary Contracts
Modern packages use the exports field in package.json to define public entry points. This prevents deep imports into internal implementation details.
// node_modules/@myorg/design-system/package.json
{
"name": "@myorg/design-system",
"exports": {
".": "./dist/index.js",
"./button": "./dist/components/button.js",
"./utils": "./dist/utils/helpers.js"
},
"main": "./dist/index.js"
}
Attempting import { internal } from '@myorg/design-system/src/internal' will throw a ERR_PACKAGE_PATH_NOT_EXPORTED error. This is intentional: it enforces semantic versioning contracts and prevents breakage when internal structures change.
Pitfall Guide
1. The TypeScript Illusion
Explanation: Developers configure paths in tsconfig.json and assume the bundler or runtime will automatically resolve them. TypeScript only uses this for static analysis.
Fix: Treat tsconfig.json paths as a type-checking contract only. Always mirror aliases in bundler and test runner configurations.
2. Case-Sensitivity Drift
Explanation: macOS and Windows ignore filename casing. import { Config } from './config' resolves against Config.ts locally but fails on Linux CI.
Fix: Enable forceConsistentCasingInFileNames: true in tsconfig.json. Add a CI step that runs find . -name "*.ts" | grep -E "[A-Z]" to audit casing conventions.
3. The exports Field Gatekeeper
Explanation: Packages with "exports" restrict access to declared entry points. Importing internal files (pkg/lib/internal) fails even if the file exists on disk.
Fix: Use only published entry points. If you need internal access, request the package maintainer to expose it, or fork/vendor the specific utility.
4. Runtime Path Mismatch
Explanation: Aliases point to src/ during development, but compiled output lands in dist/. Runtime resolution fails because the alias still points to the source directory.
Fix: Configure the bundler to rewrite aliases to relative paths during build, or use a runtime resolver that maps aliases to the dist/ directory.
5. Test Runner Isolation
Explanation: Test runners often run in isolated environments with different module resolution contexts. Jest's moduleNameMapper and Vitest's resolve.alias are not automatically synchronized.
Fix: Extract alias configuration into a shared constants file and import it into both vite.config.ts and vitest.config.ts to guarantee parity.
6. Over-Aliasing
Explanation: Creating aliases for every directory (@components, @hooks, @types) fragments the import graph and makes refactoring harder. Relative imports are often clearer for co-located files. **Fix:** Reserve aliases for cross-cutting concerns (@infra, @shared, @app`). Use relative imports for sibling or parent-child relationships within the same feature.
7. ESM vs CJS Resolution Differences
Explanation: Node.js resolves ESM and CJS modules differently. ESM requires explicit file extensions and does not support package.json main fallbacks in the same way. Aliases configured for CJS may break under ESM.
Fix: Use "moduleResolution": "NodeNext" in tsconfig.json and ensure all alias mappings include explicit extensions when targeting ESM. Avoid extensionless imports in production builds.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small monolith (< 50 files) | Relative imports | Zero configuration overhead, native resolution | Low |
| Medium/large app with cross-cutting concerns | Single root alias (@app/*) | Reduces ../ chains, improves refactoring stability | Medium (requires toolchain sync) |
| Monorepo with shared packages | Scoped aliases (@infra/*, @shared/*) | Clear boundary enforcement, prevents accidental coupling | High (requires workspace-aware tooling) |
| Library publishing | No aliases, explicit relative paths | Consumers may not support custom resolvers | Low |
| Legacy CJS migration | Runtime path polyfill (tsconfig-paths) | Bridges resolution gap without full rebuild | Medium (runtime overhead) |
Configuration Template
// shared/alias-config.ts
import path from 'path';
export const ALIAS_MAP = {
'@infra': path.resolve(__dirname, '../src/infrastructure'),
'@shared': path.resolve(__dirname, '../src/shared'),
'@app': path.resolve(__dirname, '../src')
} as const;
// vite.config.ts
import { defineConfig } from 'vite';
import { ALIAS_MAP } from './shared/alias-config';
export default defineConfig({
resolve: { alias: ALIAS_MAP }
});
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { ALIAS_MAP } from './shared/alias-config';
export default defineConfig({
resolve: { alias: ALIAS_MAP },
test: { globals: true, environment: 'node' }
});
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@infra/*": ["src/infrastructure/*"],
"@shared/*": ["src/shared/*"],
"@app/*": ["src/*"]
},
"forceConsistentCasingInFileNames": true
}
}
Quick Start Guide
- Initialize the contract: Add
baseUrl and paths to tsconfig.json. Run tsc --noEmit to verify type-checking passes.
- Sync the bundler: Copy the alias map into
vite.config.ts using path.resolve. Run vite build to confirm output generation succeeds.
- Sync the test runner: Import the same alias map into
vitest.config.ts. Run vitest run to validate test isolation.
- Validate runtime execution: Execute the compiled output with
node dist/main.js. If aliases remain unresolved, apply a build-time rewrite plugin or runtime polyfill.
- Enforce casing: Commit
forceConsistentCasingInFileNames: true and run a full CI pipeline. Fix any case mismatches before merging.
Module resolution is not a compiler feature; it is a pipeline contract. Treat every tool in your stack as an independent resolver, synchronize their configurations explicitly, and validate the entire chain before deployment. This discipline eliminates environment-specific failures and transforms module resolution from a source of friction into a reliable foundation.