| name | typescript-project-setup |
| description | Guides expert-level TypeScript project configuration: tsconfig.json with strict mode, path aliases, declaration file generation, composite projects for monorepos, and ESM configuration. Covers the decisions that determine type safety and developer experience.
Use when the user asks about setting up a TypeScript project, configuring tsconfig.json, setting up path aliases, composite projects, or TypeScript build configuration.
Do NOT use when the user asks about TypeScript type patterns (use `typescript-type-patterns`), runtime validation (use `typescript-runtime-safety`), or Node.js setup (use `nodejs-project-setup`).
|
| license | Apache-2.0 |
| metadata | {"author":"foundry-skills","version":"1.0.0","tags":"typescript best-practices template","category":"software-engineering","subcategory":"languages-runtimes","depends":"","disclaimer":"none","difficulty":"intermediate"} |
TypeScript Project Setup
When to Use
Use this skill when:
- The user is initializing a new TypeScript project and needs a tsconfig.json configured from scratch -- including a library, application, CLI tool, or monorepo package
- The user is experiencing module resolution errors such as "Cannot find module" or "File is not a module" and needs to understand how
moduleResolution settings interact with their runtime or bundler
- The user wants to configure path aliases (
@/components, ~utils) and needs both the TypeScript and the runtime/bundler sides of that configuration
- The user is setting up a monorepo (Turborepo, Nx, pnpm workspaces) and needs composite projects, project references, and shared base configs for incremental builds
- The user is building a publishable npm library and needs correct declaration file generation,
declarationMap, rootDir/outDir pairing, and proper package.json exports field alignment
- The user is migrating an existing JavaScript project to TypeScript and needs a phased
allowJs/checkJs migration strategy
- The user is configuring TypeScript for ESM output with Node.js 18+ and needs to understand
.js extension requirements in imports, "type": "module" in package.json, and node16/nodenext resolution
- The user asks about
tsc --build (project references), watch mode performance, or build output caching with TypeScript
Do NOT use this skill when:
- The user wants TypeScript type system patterns (generics, conditional types, mapped types, template literal types) -- use
typescript-type-patterns
- The user wants runtime validation (Zod, Valet, class-validator, io-ts) to complement TypeScript's compile-time types -- use
typescript-runtime-safety
- The user wants Node.js-specific project setup beyond TypeScript configuration (package.json structure, scripts, ESLint, Prettier, CI) -- use
nodejs-project-setup
- The user is asking about testing configuration (Jest, Vitest, ts-jest, type testing with
expect-type) -- use javascript-testing-patterns
- The user wants JavaScript patterns that don't involve TypeScript configuration -- use
javascript-idioms
- The user is asking about TypeScript decorators or metadata reflection -- use
typescript-decorators
- The user wants framework-specific setup where the framework provides its own tsconfig (Angular CLI, Nuxt) -- check framework-specific skills first
Process
Step 1: Classify the Project Type and Target Environment
Determine which of four project archetypes applies, because every subsequent decision depends on this:
- Application (bundler target): Code is processed by Vite, webpack, esbuild, Parcel, or Rollup. TypeScript is used only for type checking -- the bundler handles compilation. Set
"noEmit": true. Use "moduleResolution": "bundler". This unlocks the most permissive and ergonomic module resolution, supporting extensionless imports and index file resolution without requiring .js extensions.
- Library (npm publish): TypeScript emits
.js and .d.ts files that other projects consume. Set "declaration": true, "declarationMap": true, "composite": true. Use "moduleResolution": "node16" or "nodenext". This enforces the same constraints your consumers face.
- Node.js CLI or server (direct execution): TypeScript compiles to
.js files run by Node.js directly or via a loader like tsx or ts-node. Use "module": "Node16" or "NodeNext" with matching "moduleResolution". If using a bundler (esbuild for Lambda), treat it as the bundler case.
- Monorepo package: One or more packages depend on others in the same repo. Every internal package must use
"composite": true. Use project references (references array) so tsc --build can incrementally compile in dependency order. Never cross-import source files across package boundaries.
Secondary environment questions to resolve now, not later:
- Is JSX required? (React, Preact, Solid) -- determines
"jsx" setting and lib needs
- What is the minimum runtime target? ES2022 for Node.js 18+, ES2020 for Node.js 14+, never below ES2015 for new projects
- Is this a dual-CJS/ESM library? (requires two separate tsconfig files and two build outputs)
Step 2: Establish the Strict Type Safety Baseline
Start from the strictest reasonable configuration and relax only with explicit justification. Never start permissive and attempt to tighten later -- the cost is orders of magnitude higher.
The core strict flags and what each one buys you:
"strict": true -- umbrella flag that enables strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitAny, noImplicitThis, alwaysStrict, useUnknownInCatchVariables. Enable this unconditionally on new projects.
"exactOptionalPropertyTypes": true -- differentiates { prop?: string } (property absent) from { prop: string | undefined } (property present but undefined). Prevents a large class of subtle bugs where explicitly writing undefined to an optional key has different semantics than omitting it. This is not included in the strict umbrella -- add it explicitly.
"noUncheckedIndexedAccess": true -- adds | undefined to all index signature results and array element access. arr[0] becomes string | undefined instead of string. This forces you to handle the case where an index is out of bounds. Not in the strict umbrella -- add it explicitly. Note: this affects for...of loops benignly (element type is still correct) but will generate errors for array[i] patterns used without null checks.
"noImplicitReturns": true -- errors when a function has code paths that return without a value. Catches missing return statements in complex conditionals.
"noFallthroughCasesInSwitch": true -- errors on switch cases that fall through without break or return. Prevents accidental fallthrough.
"noImplicitOverride": true -- requires the override keyword when overriding a base class method. Catches typos in method names that silently create new methods instead of overriding.
"forceConsistentCasingInFileNames": true -- prevents import casing mismatches that pass on macOS/Windows (case-insensitive filesystems) but break Linux CI. Always enable.
Flags that are deliberately excluded from the default recommendation and why:
"noPropertyAccessFromIndexSignature": true -- forces bracket notation for index signature access. Useful in some codebases but creates friction with common patterns. Evaluate per project.
"allowUnusedLabels": false and "allowUnreachableCode": false -- catch dead code, but conflict with common debugging patterns. Better handled by ESLint.
Step 3: Configure Module and Module Resolution Settings
This is the single most error-prone area of TypeScript configuration. The module and moduleResolution settings are separate concerns but must be coherent:
Decision matrix:
| Scenario | module | moduleResolution | Notes |
|---|
| Vite/webpack/esbuild app | ESNext | bundler | Most ergonomic, extensionless imports |
| Next.js app | ESNext | bundler | Next.js plugin handles actual compilation |
Node.js with ESM ("type": "module") | Node16 or NodeNext | node16 or nodenext | Requires .js extensions in imports |
Node.js with CJS ("type": "commonjs") | CommonJS | node16 or nodenext | Can use extensionless imports |
| npm library (ESM output) | Node16 | node16 | Matches consumer constraints |
| npm library (dual CJS+ESM) | Two tsconfig files | See edge cases | One per output format |
Critical pitfalls in module resolution:
"moduleResolution": "node" (legacy) does not understand package.json exports field. Any library that uses exports for path mapping will resolve incorrectly. Never use this on new projects.
"moduleResolution": "bundler" cannot be used when TypeScript emits files (i.e., when noEmit is false). It assumes a bundler handles actual file resolution. Setting composite: true or declaration: true with bundler resolution will error.
- When using
"module": "Node16" or "NodeNext", TypeScript enforces that relative imports use explicit file extensions. You must write import { foo } from './utils.js' even though the source file is utils.ts. TypeScript understands that .js in an import refers to the compiled output.
"esModuleInterop": true -- enables helper functions that make CommonJS default imports work without * as syntax. Required for importing packages like path, fs, or any CJS-only package with a default export. Always enable. It also implies "allowSyntheticDefaultImports": true.
Step 4: Configure Path Aliases
Path aliases improve import ergonomics but require configuration in two places: TypeScript (for type checking) and the runtime/bundler (for actual resolution). TypeScript's paths do not affect compilation output -- they are hints for the type checker only.
TypeScript side:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@utils/*": ["./src/utils/*"]
}
}
}
baseUrl must be set for paths to work. It is relative to the tsconfig.json file location. Set it to "." to anchor paths to the repo root, or to "src" if you want shorter aliases.
Runtime/bundler side -- each tool has its own syntax:
- Vite:
resolve.alias in vite.config.ts. Use fileURLToPath(new URL('./src', import.meta.url)) for the path value in ESM configs.
- webpack:
resolve.alias in webpack.config.js.
- Jest/Vitest:
moduleNameMapper in jest config or resolve.alias in vitest config.
- Node.js (no bundler): Use
package.json imports field with "#utils/*": "./src/utils/*" syntax (note the # prefix required by Node.js subpath imports). Then use "paths": { "#utils/*": ["./src/utils/*"] } in tsconfig.
- ts-node / tsx: These tools read
tsconfig.json paths directly via tsconfig-paths package, or natively in newer versions.
Alias naming conventions that minimize confusion:
@/* for the general source root -- widely understood convention
# prefix only for Node.js subpath imports (different semantic)
- Avoid single-character aliases (
~, $) -- they conflict with shell expansion in some contexts
- Maximum two levels of nesting:
@/components/Button is fine, @/ui/components/forms/inputs/TextInput suggests a structural problem
Step 5: Configure Output Settings
Output configuration differs fundamentally between applications and libraries:
For applications (bundler handles compilation):
{
"compilerOptions": {
"noEmit": true,
"sourceMap": false
}
}
noEmit: true tells TypeScript to run only type checking. The bundler produces actual output. Source maps are irrelevant here -- the bundler generates its own.
For libraries:
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationDir": "./dist/types",
"declarationMap": true,
"sourceMap": true,
"composite": true,
"emitDeclarationOnly": false
}
}
Key output decisions for libraries:
"declarationDir" separates .d.ts files from .js files when you want a flat dist/ with types/ subdirectory. Omit it to colocate .d.ts with .js.
"declarationMap": true generates .d.ts.map files that map declaration file positions back to source. This enables "Go to Definition" in consuming projects to jump to your TypeScript source instead of the compiled declaration file. Always include it in libraries -- it has zero runtime cost.
"sourceMap": true generates .js.map files for debugger integration. Include in libraries so consumers can debug into your code.
"emitDeclarationOnly": true is useful when a separate tool (esbuild, Rollup) handles .js output but you still need TypeScript to generate .d.ts files. This is a common pattern: esbuild for fast JS compilation, tsc --emitDeclarationOnly for types.
The rootDir/outDir relationship is critical: rootDir must be an ancestor of all source files. If any source file is outside rootDir, TypeScript errors. This is why rootDir: "src" and include: ["src/**/*.ts"] must be consistent.
Incremental compilation:
"incremental": true writes a .tsbuildinfo file that caches type checking results. Dramatically speeds up repeated tsc runs. Safe to enable in both applications and libraries.
"tsBuildInfoFile": "./.tsbuildinfo" controls where the cache file is written. For monorepos, put it inside each package's directory.
"composite": true implies "incremental": true automatically.
Step 6: Configure for Specific Frameworks and Runtimes
React / Preact (Vite):
"jsx": "react-jsx" -- uses the automatic JSX transform (no import React required). Default for React 17+.
"jsx": "react" -- classic transform, requires import React from 'react' in every JSX file. Only for React 16.
"jsx": "preserve" -- emits JSX unchanged for a downstream bundler to transform. Used by Next.js (Next handles JSX compilation via SWC).
- Add
"DOM" and "DOM.Iterable" to lib for browser API types.
Next.js 14/15:
- Use
"plugins": [{ "name": "next" }] in compilerOptions for the Next.js TypeScript plugin, which provides route type checking and other Next-specific features in the IDE.
"moduleResolution": "bundler" is correct for Next.js (confirmed in Next.js docs).
- Next.js generates
next-env.d.ts -- include this file in the include array.
- Do not set
"module" to "CommonJS" -- Next.js expects "ESNext".
Node.js 18+ with native ESM:
- Set
"type": "module" in package.json.
- Set
"module": "Node16" and "moduleResolution": "node16" in tsconfig.
- All relative imports in TypeScript source must use
.js extension (not .ts).
tsc output will use .js extensions as written -- the TypeScript compiler does not rewrite extensions.
- Named exports from
package.json exports field are fully supported.
Deno:
"moduleResolution": "bundler" or "node16" both work.
- Deno supports URL imports -- add
"allowImportingTsExtensions": true for first-party .ts imports.
- Use Deno's
deno.json or deno.jsonc for import maps rather than tsconfig paths.
Step 7: Set Up Monorepo Project References
For any monorepo with multiple TypeScript packages, project references are mandatory for correct incremental builds and cross-package "Go to Definition" support.
Structure requirements:
- Every package that is referenced by another must have
"composite": true.
composite: true requires: "declaration": true, an explicit "rootDir", and all input files covered by include/files.
- The consuming package lists references:
"references": [{ "path": "../shared-types" }].
- Build with
tsc --build (or tsc -b) from the root, not tsc alone.
tsc --build --watch for development mode.
Base config inheritance:
- Create a
tsconfig.base.json at the repo root with shared strict settings.
- Each package's tsconfig extends it:
"extends": "../../tsconfig.base.json".
- The base config should NOT include
include/exclude/references -- these are package-specific.
- Override only what differs per package (module system, jsx, outDir).
The paths problem in monorepos:
- TypeScript
paths do not cross package boundaries via project references -- they are local to each tsconfig.
- Do not use
paths to reference sibling packages. Use actual npm package names (@myorg/shared-types) resolved via node_modules symlinks (pnpm workspaces, yarn workspaces, npm workspaces) plus project references.
- The
paths alias and the project reference must agree: if you alias @myorg/shared-types to a local path, also add the corresponding reference.
Step 8: Validate and Iterate
Validation must happen in this order -- each step catches different classes of problems:
tsc --noEmit -- type checking pass only. Zero errors required before any other step. On a library, run tsc --build instead to validate project references.
- IDE type resolution check -- open a consuming file, hover over an imported symbol, confirm the type shows correctly (not
any). If types show as any, module resolution or path aliases are broken.
- "Go to Definition" check -- in the IDE, press F12 on an imported symbol. It should jump to the TypeScript source file, not a
.d.ts file. If it jumps to .d.ts, declarationMap is missing or not being generated.
- Build output check -- for libraries, inspect
dist/ for: .js files, .d.ts files, .d.ts.map files, .js.map files. Missing any of these breaks consumers.
- Consumer simulation -- create a temporary consuming project, install your library from the local
dist/, and verify imports, types, and source maps work end-to-end. This catches package.json exports misalignments.
Output Format
When providing TypeScript project configuration, deliver the following structure:
Project Assessment Summary
Project type: [Application | Library | Node.js service | Monorepo package]
Bundler/runtime: [Vite | webpack | Node.js native | Next.js | Deno | none]
Output target: [Browser | Node.js 18+ | Node.js 16+ | Dual CJS+ESM]
JSX required: [Yes (React 17+) | Yes (React 16) | No]
Path aliases: [Yes -- list aliases | No]
Monorepo: [Yes -- list tools | No]
Key constraints: [List any non-standard requirements]
tsconfig.json Files
Provide all tsconfig files needed for the project, labeled clearly:
Files to create:
tsconfig.base.json (if monorepo)
tsconfig.json (always)
tsconfig.build.json (if library with separate type-check and build configs)
packages/*/tsconfig.json (if monorepo)
Each tsconfig with inline comments explaining non-obvious settings:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"sourceMap": true,
"incremental": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
Bundler Alias Configuration (when applicable)
Provide the matching alias configuration for the build tool:
import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
package.json Exports (for libraries)
{
"name": "@myorg/my-library",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"]
}
Validation Checklist
[ ] tsc --noEmit passes with zero errors
[ ] tsc --build passes (monorepos / libraries with composite)
[ ] IDE shows correct types on imported symbols (not `any`)
[ ] "Go to Definition" jumps to source .ts files (not .d.ts)
[ ] dist/ contains .js, .d.ts, .d.ts.map, .js.map (libraries)
[ ] Path aliases resolve in IDE and in build output
[ ] No circular project references (monorepos)
Rules
-
ALWAYS enable "strict": true. Never ship a tsconfig without it. The time cost of enabling strict on an existing codebase is 10--50x higher than starting strict. If a user asks to disable strict for "simplicity," explain the trade-off and offer the phased migration path instead.
-
ALWAYS add "exactOptionalPropertyTypes": true and "noUncheckedIndexedAccess": true explicitly. These are not included in the strict umbrella and address a large proportion of real production bugs. They are the two most impactful non-umbrella flags.
-
NEVER use "moduleResolution": "node" (legacy) on new projects. It ignores the exports field in package.json, which means any library using path mapping (like @tanstack/query/react) will resolve incorrectly. The correct minimum is "node16" for emit scenarios or "bundler" for non-emit scenarios.
-
NEVER use "moduleResolution": "bundler" when TypeScript must emit files. bundler resolution is incompatible with TypeScript emission (composite: true, declaration: true, or any config without noEmit: true). This combination produces a TypeScript compiler error: "Option 'moduleResolution: bundler' requires 'noEmit: true' or 'emitDeclarationOnly: true'."
-
ALWAYS pair "paths" configuration with runtime resolution. TypeScript paths are erased at compile time -- they do not affect the compiled output. A path alias that is not also configured in the bundler (Vite, webpack), test runner (Jest moduleNameMapper, Vitest resolve.alias), or Node.js (package.json imports field) will cause runtime "Cannot find module" errors even though TypeScript type-checks correctly.
-
ALWAYS use "composite": true and project references for monorepo packages. Without project references, tsc --build cannot determine build order and will not produce correct incremental output. Without composite, the TypeScript server cannot cache type information for referenced packages, causing IDE slowness proportional to monorepo size.
-
NEVER cross-import source .ts files from a sibling package in a monorepo. Always import through the package's public API (its main/exports field pointing to dist/), or through project references that consume the compiled output. Direct source imports break build caching and create tight coupling that makes packages non-independently deployable.
-
ALWAYS set "isolatedModules": true when using esbuild, SWC, or Babel for compilation. These tools compile files one-at-a-time without access to the full type graph, which means they cannot handle const enum, namespace merging, or re-exported types without explicit type imports. isolatedModules surfaces these incompatibilities at compile time rather than silently producing incorrect output. Also enforce "verbatimModuleSyntax": true as a stricter alternative that enforces import type for all type-only imports.
-
NEVER set "target" below "ES2020" for new projects without explicit IE11 or legacy environment requirements. Targeting ES5 or ES6 forces TypeScript to emit large polyfill helpers (__awaiter, __generator, __assign) for async/await and object spread. Modern bundlers tree-shake and conditionally polyfill based on target browsers far more efficiently than TypeScript's emit helpers. Minimum target for Node.js 18+ is ES2022 to use native Array.at(), Error.cause, Object.hasOwn(), and top-level await.
-
ALWAYS include "skipLibCheck": true in application tsconfigs. Library declaration files often contain type errors due to version mismatches between a library's @types package and the library itself. skipLibCheck skips type-checking of all .d.ts files in node_modules. The performance gain on medium-to-large projects is 30--60% faster type checking. For libraries you publish, do NOT skip lib checking on your own .d.ts output -- run tsc on your output to verify your declarations are valid.
-
ALWAYS regenerate tsconfig.tsbuildinfo when changing compilerOptions significantly. Stale incremental build caches cause TypeScript to skip re-checking files it should re-check. Run tsc --build --clean to wipe the cache when changing strict, target, module, moduleResolution, or paths.
-
NEVER mix "module": "CommonJS" with "type": "module" in package.json. If package.json has "type": "module", all .js files are interpreted as ESM. If TypeScript emits CommonJS into those files, Node.js will throw SyntaxError: Cannot use import statement in a module. Use .cjs extension for CommonJS output in an ESM package, or change "type" to "commonjs" and use .mjs for ESM files.
Edge Cases
Migrating from JavaScript to TypeScript Incrementally
Start permissive and tighten. Never attempt a big-bang migration:
- Add
tsconfig.json with "allowJs": true, "checkJs": false, "noEmit": true, "strict": false.
- Rename the entry point file from
.js to .ts. Fix only the errors introduced by that one file.
- Work outward from leaf modules (pure utilities with no imports) toward the entry point. Leaf modules have no dependencies to fight with.
- After the codebase is fully
.ts, enable flags one at a time in this order: noImplicitAny → strictNullChecks → strictFunctionTypes → strict: true → exactOptionalPropertyTypes → noUncheckedIndexedAccess.
- Use
// @ts-ignore and // @ts-expect-error sparingly as temporary suppressions with a TODO comment referencing a ticket. Never suppress errors permanently.
- The
as unknown as TargetType double cast is acceptable during migration when the type relationship is genuinely established by program logic TypeScript cannot verify -- but document it.
Dual CJS and ESM Library Output
Publishing a library that works in both CommonJS and ESM environments requires two separate tsconfig files and careful package.json exports:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "Node16",
"moduleResolution": "node16",
"outDir": "./dist/esm",
"declaration": false
}
}
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node16",
"outDir": "./dist/cjs",
"declaration": true,
"declarationDir": "./dist/types"
}
}
Build script: tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json
package.json exports field:
{
"exports": {
".": {
"import": { "types": "./dist/types/index.d.ts", "default": "./dist/esm/index.js" },
"require": { "types": "./dist/types/index.d.ts", "default": "./dist/cjs/index.js" }
}
}
}
The CJS output directory should contain a package.json with { "type": "commonjs" } to prevent Node.js from misinterpreting .js files as ESM. The ESM output directory gets { "type": "module" }.
Non-TypeScript File Imports (CSS Modules, SVG, WASM)
TypeScript does not know about non-standard file types. Create declaration files in src/ to teach it:
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.svg' {
import type { FC, SVGProps } from 'react'
const ReactComponent: FC<SVGProps<SVGSVGElement>>
export { ReactComponent }
export default ReactComponent
}
declare module '*.wasm' {
const content: WebAssembly.Module
export default content
}
Keep declaration files in src/ and include them in include array. Never add them to node_modules or global scope unless they are genuinely global types.
TypeScript with Decorators (Legacy and Standard)
TypeScript supports two decorator systems with different tsconfig requirements:
- Legacy decorators (TypeScript < 5.0, Angular, NestJS): Require
"experimentalDecorators": true and "emitDecoratorMetadata": true. The latter requires reflect-metadata as a runtime dependency. These decorators use a different syntax and are not compatible with the TC39 standard decorators.
- Standard decorators (TypeScript 5.0+, TC39 Stage 3): Do NOT set
experimentalDecorators. Standard decorators are enabled by default in TypeScript 5+. They do not support metadata emission -- if you need metadata, use a separate mechanism.
NestJS currently requires legacy decorators. Angular 15+ supports standard decorators. Do not mix the two systems.
Path Aliases Breaking in Jest or Vitest
The most common post-setup failure: TypeScript is happy, the build works, but tests fail with "Cannot find module '@/utils/format'". This is because Jest and Vitest resolve modules independently of TypeScript and the application bundler.
For Jest:
{
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/src/$1"
}
}
For Vitest:
import { defineConfig } from 'vitest/config'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
test: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
Alternatively, Vitest can extend the Vite config: import { defineConfig } from 'vitest/config' combined with mergeConfig(viteConfig, vitestConfig) -- this automatically inherits Vite's resolve.alias settings and avoids duplication.
Circular Project References in Monorepos
tsc --build detects circular references between packages and errors immediately. Common causes:
- Package A references Package B, and Package B references Package A.
- A shared utility package that imports from a feature package that it should not know about.
- Incorrectly placed types that should be in a separate base package.
Resolution strategy:
- Draw the dependency graph explicitly (package names as nodes, references as directed edges).
- The graph must be a DAG (directed acyclic graph). Any cycle must be broken.
- Extract the shared type or utility into a third package that neither A nor B imports from each other.
- Commonly:
@myorg/types (no dependencies), @myorg/utils (depends on types), @myorg/feature (depends on both). Never let types or utils depend on feature packages.
skipLibCheck Hiding Real Errors
skipLibCheck: true skips ALL .d.ts files, including your own generated declarations if you mistakenly include dist/ in include. This can hide malformed declaration output.
Safe practice:
- Add
"dist" and "**/*.d.ts" to exclude in your source tsconfig.
- Run a separate type validation step in CI:
tsc --noEmit -p tsconfig.check-declarations.json where that config has skipLibCheck: false and includes only the dist/ types.
- Use
@arethetypeswrong/cli (attw) to validate your published package's types resolve correctly from multiple module resolution modes.
Example
Input: "I'm building a date formatting library called tempo-date that I plan to publish to npm. It needs to work in both ESM and CommonJS environments. I'm using TypeScript 5.3 with esbuild for JavaScript compilation and tsc --emitDeclarationOnly for types. The library will be used in Node.js 18+ and browser (via bundlers). No React, no path aliases. How should I configure TypeScript?"
Project Assessment
Project type: Library (npm publish)
Bundler/runtime: esbuild (JS compilation), tsc (declaration only)
Output target: Dual CJS+ESM -- Node.js 18+ and browser bundlers
JSX required: No
Path aliases: No
Monorepo: No
Key constraints: esbuild requires isolatedModules; dual output requires two tsconfig files;
declarations must be correct for both module formats
File Structure
tempo-date/
src/
index.ts # Public API entry point
format.ts # Core formatting logic
parse.ts # Parsing logic
locale.ts # Locale handling
types.ts # Shared internal types
declarations.d.ts # (not needed -- pure TS library)
dist/
esm/
index.js # ESM output (esbuild)
package.json # { "type": "module" }
cjs/
index.js # CJS output (esbuild)
package.json # { "type": "commonjs" }
types/
index.d.ts # Declaration files (tsc)
index.d.ts.map # Declaration maps (tsc)
tsconfig.json # Base config for IDE and type checking
tsconfig.build.json # Extends base -- used for tsc --emitDeclarationOnly
package.json
build.ts # esbuild build script
tsconfig.json (base -- used by IDE and tsc --noEmit)
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "Node16",
"moduleResolution": "node16",
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"incremental": true,
"tsBuildInfoFile": "./.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
tsconfig.build.json (declaration emission only)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"declarationDir": "./dist/types",
"rootDir": "./src",
"composite": true,
"incremental": false,
"tsBuildInfoFile": undefined
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
build.ts (esbuild script for JS compilation)
import { build } from 'esbuild'
const sharedConfig = {
entryPoints: ['src/index.ts'],
bundle: true,
platform: 'neutral' as const,
target: 'es2022',
external: [],
minify: false,
sourcemap: true,
}
await build({
...sharedConfig,
format: 'esm',
outfile: 'dist/esm/index.js',
})
await build({
...sharedConfig,
format: 'cjs',
outfile: 'dist/cjs/index.js',
})
import { writeFile } from 'node:fs/promises'
await writeFile('dist/esm/package.json', JSON.stringify({ type: 'module' }))
await writeFile('dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }))
console.log('Build complete')
package.json
{
"name": "tempo-date",
"version": "1.0.0",
"description": "Fast, type-safe date formatting and parsing",
"license": "MIT",
"type": "module",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/types/index.d.ts",
"default": "./dist/cjs/index.js"
}
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsx build.ts && tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist .tsbuildinfo"
},
"devDependencies": {
"esbuild": "^0.20.0",
"tsx": "^4.0.0",
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18.0.0"
}
}
Import Style Required Inside src/ (Node16 resolution)
Because "module": "Node16" is set, TypeScript enforces explicit .js extensions in relative imports. This is non-negotiable with this configuration:
export { format } from './format.js'
export { parse } from './parse.js'
export { createLocale } from './locale.js'
export type { FormatOptions, ParseResult } from './types.js'
export { format } from './format'
export { format } from './format.ts'
TypeScript resolves './format.js' to ./format.ts in source -- the .js refers to the compiled output that will exist at runtime. This is the correct mental model.
CI Validation Script
#!/bin/sh
set -e
echo "Running type check..."
npx tsc --noEmit
echo "Building..."
npm run build
echo "Checking dist structure..."
test -f dist/esm/index.js || (echo "Missing ESM output" && exit 1)
test -f dist/cjs/index.js || (echo "Missing CJS output" && exit 1)
test -f dist/types/index.d.ts || (echo "Missing declarations" && exit 1)
test -f dist/types/index.d.ts.map || (echo "Missing declaration maps" && exit 1)
echo "Validating type exports..."
npx attw --pack .
echo "All checks passed."
Validation Checklist
[x] tsc --noEmit passes with zero errors
[x] tsc -p tsconfig.build.json produces dist/types/index.d.ts and .d.ts.map
[x] esbuild produces dist/esm/index.js and dist/cjs/index.js
[x] dist/esm/package.json contains { "type": "module" }
[x] dist/cjs/package.json contains { "type": "commonjs" }
[x] package.json exports field covers both "import" and "require" conditions
[x] Relative imports in src/ use .js extensions (enforced by node16 resolution)
[x] No import type violations (enforced by verbatimModuleSyntax)
[x] attw reports no type resolution errors for any module format
[x] "Go to Definition" in a consuming project resolves to src/ via declarationMap
The key insight for this library setup: esbuild handles JavaScript compilation at high speed, while tsc --emitDeclarationOnly handles the slower but necessary declaration file generation. These are separate concerns with separate tooling. TypeScript's verbatimModuleSyntax ensures the two tools stay in sync by enforcing explicit import type on type-only imports -- a requirement both tools share. The node16 module resolution mode validates that all imports will resolve correctly in Node.js 18+ ESM environments before any code leaves the developer's machine.