Build Systems: MSBuild vs. the JS Build Toolchain
For .NET engineers who know:
dotnet build, MSBuild,.csprojsettings, and the C# compilation pipeline You’ll learn: What actually happens when you build a TypeScript project — the full chain fromtscthrough bundlers — and whichtsconfig.jsonsettings map to what you already configure in.csprojTime: 15-20 min read
The .NET Way (What You Already Know)
When you run dotnet build, MSBuild orchestrates a pipeline that is largely invisible because Microsoft owns the entire chain. The C# compiler (csc or Roslyn) takes your source files, resolves project references from .csproj, compiles to IL (Intermediate Language), and writes a .dll to bin/. The runtime (CLR/CoreCLR) JIT-compiles that IL to native code at execution time. You configure this pipeline through .csproj properties: <TargetFramework>, <Nullable>, <ImplicitUsings>, <LangVersion>, <Optimize>. If you need to see what’s actually happening, dotnet build -v detailed will show you the full MSBuild task graph, but most engineers never need to look.
The key characteristics of this model: one compiler, one build tool, one runtime, one output format. Everything is integrated and controlled by Microsoft. The tradeoff is that customizing the pipeline requires MSBuild expertise that most engineers don’t have.
<!-- The .csproj you know — this drives the entire build -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>13.0</LangVersion>
<Optimize>true</Optimize>
<AssemblyName>MyApp</AssemblyName>
<RootNamespace>MyApp</RootNamespace>
</PropertyGroup>
</Project>
MSBuild also handles: dependency resolution (NuGet restore), asset copying, linked files, conditional compilation (#if DEBUG), code generation (source generators), and packaging. It is a general-purpose build engine that happens to be the primary one for .NET.
The JS/TS Way
The JavaScript/TypeScript build story is fragmented because there is no single vendor controlling the chain. Instead, several tools each own one layer:
graph TD
A["Source (.ts)"]
B["TypeScript Compiler (tsc)\nType erasure, downleveling,\nmodule transformation"]
C["JavaScript (.js)"]
D["Bundler\n(Vite / Webpack / esbuild / Turbopack)\nTree-shaking, code-splitting, minification,\nasset handling, hot module replacement"]
E["Optimized bundle (.js / .css / assets)"]
F["Runtime (Node.js / Browser V8)"]
A --> B --> C --> D --> E --> F
Unlike MSBuild, these tools do not automatically compose. Each one has its own configuration file, its own CLI, its own plugin ecosystem, and its own opinion about how the pipeline should work. The good news: most frameworks (Next.js, NestJS, Vite-based projects) wire the chain together for you and expose only the configuration you actually need to touch.
Step 1: TypeScript Compiler (tsc)
tsc is a type checker and transpiler. It does two distinct things, and it helps to separate them mentally:
Type checking — tsc reads your TypeScript, builds a type graph, and reports errors. This is equivalent to Roslyn’s semantic analysis pass. No output is produced; it just tells you whether your types are correct.
Transpilation — tsc strips TypeScript syntax (types, interfaces, decorators-in-some-configurations) and produces plain JavaScript. This is closer to what the C# compiler does when it lowers high-level C# syntax to IL — except TypeScript lowers to JavaScript, not binary code.
Run type checking without emitting files:
# Type-check only (no output files) — equivalent to building to check errors
npx tsc --noEmit
# Compile to JavaScript
npx tsc
# Watch mode — equivalent to dotnet watch build
npx tsc --watch
The key thing to understand: tsc does not optimize. It does not tree-shake, minify, or bundle. It produces one .js file per .ts file, or a concatenated output if configured. For browser applications, raw tsc output is rarely what you ship. You need a bundler.
For NestJS (Node.js backend), tsc output is often sufficient — you run node dist/main.js directly. For Next.js (browser applications), Next.js runs its own build pipeline that uses the TypeScript compiler internally but you never invoke tsc directly for production.
Step 2: Bundlers
Bundlers solve the browser’s module loading problem. A browser cannot natively import 500 separate JavaScript files efficiently (though HTTP/2 mitigates this somewhat), and node_modules references do not work in a browser. A bundler:
- Starts from an entry point (
main.ts,index.ts) - Follows all
importstatements recursively, building a dependency graph - Removes dead code that is never imported (tree-shaking)
- Splits the graph into chunks to enable lazy loading (code-splitting)
- Minifies the output (removes whitespace, renames variables)
- Handles non-JS assets (CSS, images, fonts)
- Outputs optimized bundles for deployment
There are four bundlers you will encounter:
Webpack — the industry standard from roughly 2015-2022. Highly configurable, enormous plugin ecosystem, complex configuration. If you are joining an existing project, you will likely encounter it. webpack.config.js can grow to several hundred lines of configuration. Vite largely replaced it for new projects.
Vite — the modern default for frontend projects. Built on esbuild (for development speed) and Rollup (for production bundling). Configuration is minimal by design. Nearly all new React, Vue, and Svelte projects use Vite unless they use a meta-framework. Development mode uses native ES modules in the browser with no bundling step, which makes hot module replacement near-instant.
esbuild — written in Go, extremely fast (10-100x faster than Webpack). Used internally by Vite for the dev server transformation step. Can be used standalone for simple bundling scenarios. Less configurable than Webpack but fast enough to make configuration concerns moot for many use cases.
Turbopack — Vercel’s Rust-based bundler, currently used in Next.js development mode. Not production-ready for standalone use yet.
A practical summary: you will probably not choose your bundler directly. If you are building a standalone React or Vue app, use Vite. If you are using Next.js, it handles bundling. If you are using NestJS, you probably do not need a bundler at all — just tsc.
Step 3: Framework Build Pipelines
This is where the complexity is hidden from you:
Next.js runs next build, which internally invokes the TypeScript compiler for type checking, then uses its own bundling pipeline (SWC for transformation, Webpack or Turbopack for bundling) to produce optimized server and client bundles. You rarely configure the bundler directly. Next.js has a next.config.js/next.config.ts for framework-level settings.
NestJS runs nest build (or tsc directly), which compiles TypeScript to JavaScript in the dist/ directory. NestJS’s build is straightforward — it is a Node.js application, so there is no browser bundling required. The nest-cli.json file controls build options.
Vite-based apps (standalone React, Vue) run vite build, which runs TypeScript type checking (via vue-tsc or tsc) and then Rollup-based bundling in one command.
# Next.js project
next build # type-check + bundle + optimize
next dev # development server with HMR
# NestJS project
nest build # tsc compilation to dist/
nest start --watch # watch mode with auto-restart
# Vite project (standalone React/Vue)
vite build # type-check + bundle
vite dev # development server with HMR
tsconfig.json in Depth
tsconfig.json is the closest equivalent to .csproj for TypeScript compilation settings. The mapping is not perfect — tsconfig.json controls the compiler only, not the full build pipeline — but understanding the correspondence makes it immediately familiar.
// tsconfig.json — annotated with .csproj equivalents
{
"compilerOptions": {
// --- OUTPUT TARGETING ---
// What JS version to emit. Equivalent to <TargetFramework>.
// "ES2022" means use modern JS syntax; "ES5" downlevels to IE-compatible JS.
// Next.js and NestJS on modern Node.js: use "ES2022" or later.
"target": "ES2022",
// Module system for emitted code. Equivalent to choosing assembly output type.
// "ESNext" or "ES2022" for native ES modules (import/export in output).
// "CommonJS" for Node.js-compatible require() output.
// "NodeNext" for modern Node.js with native ESM support.
// NestJS: "CommonJS". Next.js: handled internally, don't set manually.
"module": "CommonJS",
// How imports are resolved. The most important setting for avoiding import errors.
// "NodeNext" — respects package.json "exports" field, requires .js extensions.
// "Bundler" — assumes a bundler handles resolution; most permissive, used with Vite.
// "Node16" — older Node.js-compatible resolution.
// No .NET equivalent — .NET assembly resolution is implicit.
"moduleResolution": "NodeNext",
// Output directory for compiled JS. Equivalent to <OutputPath> or bin/ directory.
"outDir": "./dist",
// Root directory of source files. Equivalent to the project root in .csproj.
"rootDir": "./src",
// --- TYPE CHECKING ---
// Enables all strict type checking flags. Equivalent to <Nullable>enable</Nullable>
// plus Roslyn analyzer warnings. Always enable this — the cost of disabling it
// is technical debt that compounds fast.
"strict": true,
// Stricter property initialization checks (part of strict, but worth knowing).
// Equivalent to nullable reference types in C#.
"strictNullChecks": true,
// Disallow implicit 'any' types. Without this, TypeScript silently widens
// unresolvable types to 'any', defeating the purpose of the type system.
"noImplicitAny": true,
// --- INTEROP ---
// Allow importing CommonJS modules with ES module syntax.
// Required when mixing import/require in Node.js projects.
"esModuleInterop": true,
// Allow importing JSON files directly. No .NET equivalent.
"resolveJsonModule": true,
// Preserve JSX syntax for React (let the bundler handle it) vs. compiling it.
// "preserve" for Next.js/Vite (they handle JSX transformation).
// "react-jsx" for projects where tsc handles JSX.
"jsx": "preserve",
// --- PATH ALIASES ---
// Import path aliases. Equivalent to setting namespace aliases or project refs.
// "@/*" means "anything starting with @/ maps to ./src/*"
// After configuring this, `import { foo } from '@/lib/foo'` works anywhere.
// Note: bundlers (Vite, webpack) must also be configured to respect these paths.
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@lib/*": ["./src/lib/*"]
},
// --- EMIT CONTROL ---
// Do not emit output files — type-check only. Used in CI for checking without
// building, or when the bundler handles transpilation.
// Equivalent to running Roslyn as an analyzer without producing output.
"noEmit": false,
// Include type declarations from @types/* packages (e.g., @types/node).
// Types are loaded automatically from node_modules/@types — no explicit includes needed
// unless you want to restrict which ones are loaded.
"types": ["node"]
},
// Which files to include. Equivalent to <Compile Include="..." /> in .csproj.
// Default: all .ts/.tsx files in the project root.
"include": ["src/**/*"],
// Which files to exclude. node_modules is excluded by default.
"exclude": ["node_modules", "dist"]
}
The strict flag deserves special attention. It is a shorthand that enables multiple sub-flags: strictNullChecks, noImplicitAny, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, and alwaysStrict. A project without strict: true is a project where TypeScript cannot be trusted, because the type system has holes large enough to drive a truck through. Treat enabling strict on an existing codebase as a migration task, not a one-line change — you will find dozens of latent type errors.
The moduleResolution setting is where most .NET engineers run into trouble. Here is the practical guide:
| Setting | Use When | Behavior |
|---|---|---|
"NodeNext" | NestJS, any Node.js project with "type": "module" in package.json | Requires .js extensions on relative imports even in .ts files. Resolves package.json exports field. |
"Node16" | Older Node.js projects | Similar to NodeNext, slightly older semantics |
"Bundler" | Any project using Vite, Next.js, or webpack | No extension requirements. Assumes the bundler will resolve everything. |
"Node" | Legacy projects | The old default. Do not use for new projects. |
The target and module Matrix
These two settings interact in ways that confuse .NET engineers because there is no equivalent distinction in .csproj:
targetcontrols what JavaScript syntax is emitted.ES2022emits modern syntax (optional chaining, nullish coalescing, class fields).ES5downlevels to older syntax that older browsers understand.modulecontrols how imports and exports are emitted.ESNextkeepsimport/export.CommonJSconverts them torequire()/module.exports.
These are independent settings, which is why you can have a target: "ES2022" (modern JS syntax) with module: "CommonJS" (Node.js-style imports) — this is exactly what NestJS uses.
For browser apps, modern frameworks handle this for you: Next.js and Vite set their own targets internally. For Node.js apps (NestJS), the typical configuration is:
// tsconfig.json for NestJS (Node.js backend)
{
"compilerOptions": {
"module": "CommonJS",
"target": "ES2022",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"rootDir": "./src"
}
}
Note the experimentalDecorators and emitDecoratorMetadata flags — these are required for NestJS’s decorator-based DI system. Without emitDecoratorMetadata, the runtime reflection that NestJS uses to resolve constructor parameter types does not work. See Article 2.5 for the full explanation of decorators.
Multiple tsconfig Files
In a real project, you will often see multiple tsconfig files, not just one. This is equivalent to having separate build configurations (Debug/Release) but more granular:
tsconfig.json ← Base configuration (shared settings)
tsconfig.build.json ← Production build (excludes tests)
tsconfig.test.json ← Test configuration (includes test files)
// tsconfig.build.json — extends base, used for production builds
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
}
// tsconfig.test.json — extends base, used by Vitest/Jest
{
"extends": "./tsconfig.json",
"include": ["src/**/*", "test/**/*"]
}
The extends keyword works like inheritance — the child config inherits all settings from the parent and overrides only what it specifies. There is no direct .csproj equivalent; the closest is build configuration transforms or the Directory.Build.props shared properties pattern in multi-project solutions.
What Tree-Shaking Actually Means
Tree-shaking is the bundler equivalent of the C# linker removing unused code from single-file executables. It works by static analysis of import/export statements: if you import { Button } from './components' but never use Button, the bundler removes it from the output bundle.
Tree-shaking only works with ES modules (import/export), not CommonJS (require()). This is one reason modern libraries ship as ES modules even when they also provide CommonJS builds.
// components/index.ts — exports many things
export { Button } from './Button';
export { Modal } from './Modal';
export { Table } from './Table';
export { Chart } from './Chart'; // 500KB library
// page.tsx — only imports Button
import { Button } from './components';
// After tree-shaking: Chart is NOT in the bundle.
// The bundler can prove it is never used.
The implication for library authors: side effects in module-level code defeat tree-shaking. Code that registers global state when a module is import-ed prevents the bundler from safely removing it even if nothing from that module is used. Well-designed libraries mark themselves "sideEffects": false in package.json to tell bundlers they are safe to tree-shake.
Code-Splitting
Code-splitting is the bundler feature that splits your application into multiple chunks loaded on demand, rather than one large bundle loaded upfront. The browser equivalent of lazy loading assemblies.
In Next.js, code-splitting is automatic: each page gets its own chunk, and dynamic imports create additional split points:
// next.js — static import (included in initial bundle)
import { HeavyChart } from './HeavyChart';
// next.js — dynamic import (creates a separate chunk, loaded on demand)
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <p>Loading chart...</p>,
});
In Vite, you use standard dynamic imports:
// Vite — creates a separate chunk loaded when this import is executed
const { HeavyChart } = await import('./HeavyChart');
You do not need to configure code-splitting manually in most frameworks — the tooling handles it. You do need to be aware of it when deciding where to place large dependencies.
Key Differences
| Concern | .NET / MSBuild | JS/TS Toolchain |
|---|---|---|
| Compilation | Roslyn compiles C# → IL | tsc compiles TypeScript → JavaScript (type erasure, not compilation to bytecode) |
| Build orchestration | MSBuild (integrated) | Vite / Webpack / esbuild / Next.js (separate, composed) |
| Runtime compilation | CLR JIT-compiles IL → native at runtime | V8 JIT-compiles JavaScript → native at runtime (similar model) |
| Build configuration | .csproj + MSBuild properties | tsconfig.json + vite.config.ts + next.config.ts (three separate files) |
| Output | .dll (IL bytecode) | .js files (plain text JavaScript) |
| Type safety at runtime | Types enforced by CLR | Types erased at runtime — JavaScript has no types |
| Tree-shaking | IL Linker for AOT/self-contained | Built into all modern bundlers |
| Code-splitting | N/A (assemblies loaded on demand) | Automatic in Next.js/Vite, manual dynamic imports |
| Hot reload | dotnet watch (full rebuild) | HMR via Vite/Next.js (module-level replacement, no full reload) |
| Strict mode equivalent | <Nullable>enable</Nullable> + analyzers | "strict": true in tsconfig |
| Path aliases | <PackageReference> / namespace imports | "paths" in tsconfig + bundler config |
| Multiple build configs | Debug/Release configurations | Multiple tsconfig files + environment variables |
| Language version | <LangVersion> | "target" in tsconfig (controls output JS syntax) |
Gotchas for .NET Engineers
1. TypeScript types do not exist at runtime — tsc is not your validator
This is the most important mental model shift in this entire article. In C#, if you declare string Name { get; set; }, the CLR enforces that constraint at runtime. In TypeScript, name: string is erased by tsc and the runtime JavaScript has no knowledge it ever existed.
// This looks like a type-safe function
function processUser(user: { name: string; age: number }) {
console.log(user.name.toUpperCase());
}
// This compiles without error but throws at runtime
const response = await fetch('/api/user');
const data = await response.json(); // type: any
processUser(data); // No error from tsc — data could be anything
// If the API returns { Name: 'Alice', age: null }, you get:
// TypeError: Cannot read properties of undefined (reading 'toUpperCase')
The solution is runtime validation with Zod at every boundary where external data enters your application (API responses, environment variables, form submissions). See Article 2.3 for the full treatment. The rule of thumb: never trust JSON.parse() or response.json() without validating the shape.
2. Configuring paths in tsconfig is not enough — your bundler also needs them
Path aliases ("paths": { "@/*": ["./src/*"] }) tell tsc how to resolve imports during type checking. They do not tell the bundler (Vite, webpack, Next.js) how to resolve them at build time. If you configure tsconfig.json paths but not the bundler, your app will type-check successfully but fail at runtime.
// This import works for tsc but may fail at bundle time
import { formatDate } from '@/lib/dates';
Each bundler has its own configuration:
// vite.config.ts — must mirror tsconfig paths
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
// next.config.ts — Next.js reads tsconfig paths automatically
// No extra configuration needed for Next.js
// webpack.config.js — must mirror tsconfig paths
module.exports = {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
};
Next.js is the exception: it reads tsconfig.json paths and configures webpack/Turbopack automatically. In Vite projects, you must duplicate the configuration.
3. noEmit: true does not mean your build is type-safe
In CI pipelines, a common pattern is to run tsc --noEmit to type-check without producing output. The gotcha: tsc --noEmit only checks files that are included in your tsconfig.json. If your bundler (Vite, Next.js) has a separate mechanism for determining which files to process, there may be files that the bundler builds but tsc --noEmit never sees.
Additionally, tsc --noEmit will not catch errors in files excluded by the exclude field, including test files if they are excluded from the build tsconfig. Run a separate tsc --noEmit using tsconfig.test.json (or your test tsconfig) to catch type errors in test files.
# CI pipeline — type-check production code AND tests
npx tsc --noEmit -p tsconfig.json
npx tsc --noEmit -p tsconfig.test.json
4. experimentalDecorators and emitDecoratorMetadata must be set for NestJS — without them, DI silently fails
NestJS relies on the reflect-metadata package to perform runtime reflection on constructor parameter types. This mechanism requires emitDecoratorMetadata: true in tsconfig, which causes tsc to emit metadata about parameter types alongside the compiled JavaScript. Without it, NestJS cannot determine what types to inject, and you will get cryptic errors at startup — or, worse, undefined injected values.
The TypeScript decorator standard (Stage 3 TC39 proposal) does not support emitDecoratorMetadata. If you are using a project template that has switched to the new decorator standard without experimentalDecorators, NestJS will not work. Always verify both flags are set when setting up a NestJS project.
5. Build output is readable JavaScript — not a security boundary
In .NET, distributing a .dll without source provides some level of obfuscation (though not real security). TypeScript compiles to readable JavaScript. Even with minification, your bundled output is plain text that anyone can read, beautify, and analyze. Source maps — if deployed — make it trivially readable.
Do not put secrets, API keys, or business logic that should remain private in client-side JavaScript. This applies equally to React, Vue, and any browser-targeted code. Server Components in Next.js are one mechanism for running code on the server only; anything in a Client Component runs in the browser and is visible.
6. The module and moduleResolution settings interact in non-obvious ways
If you set "module": "NodeNext" (the modern Node.js ESM-native setting), TypeScript requires that relative imports include the .js extension — even in .ts files:
// With "moduleResolution": "NodeNext" — required
import { foo } from './foo.js'; // .js extension in a .ts file (intentional)
// With "moduleResolution": "Bundler" — both work
import { foo } from './foo';
import { foo } from './foo.js';
The .js extension is correct even though the file on disk is foo.ts. This is because after compilation, the file will be foo.js, and Node.js resolves ESM imports before TypeScript runs. Most .NET engineers find this confusing and try to fix it by removing the extension, which breaks Node.js module resolution. If you are on NestJS and encounter this, check whether moduleResolution is set to NodeNext or Node.
Hands-On Exercise
This exercise builds your intuition for what each layer of the build chain actually does. You will take a simple TypeScript file through each stage manually, then examine how Next.js handles the same process automatically.
Part 1: The raw tsc pipeline
Create a minimal TypeScript project from scratch (no framework):
mkdir build-experiment && cd build-experiment
npm init -y
npm install typescript --save-dev
npx tsc --init
Edit tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"strict": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
Create src/user.ts:
interface User {
id: number;
name: string;
email: string | null;
}
function formatUser(user: User): string {
return `${user.name} (${user.email ?? 'no email'})`;
}
export { User, formatUser };
Create src/main.ts:
import { User, formatUser } from './user';
const user: User = { id: 1, name: 'Alice', email: null };
console.log(formatUser(user));
Compile and inspect the output:
npx tsc
cat dist/user.js
cat dist/main.js
Observe: the interface User is completely absent from dist/user.js. The type annotation user: User is absent from dist/main.js. What remains is plain JavaScript — identical behavior, zero type information.
Now try breaking the types and see that tsc catches it:
// In src/main.ts — add this broken call
const badUser: User = { id: 'not-a-number', name: 'Bob', email: 'bob@example.com' };
npx tsc --noEmit
# error TS2322: Type 'string' is not assignable to type 'number'.
Part 2: Examine a Next.js build
In an existing Next.js project (or create one with npx create-next-app@latest):
# Examine the tsconfig.json and note: "moduleResolution": "bundler"
cat tsconfig.json
# Run the production build and examine what it produces
npm run build
# Look at what was generated
ls -la .next/
ls -la .next/static/chunks/
The .next/static/chunks/ directory contains code-split bundles. Each chunk is a separate JavaScript file that the browser loads on demand. Compare the file count to your source files — you will have far fewer bundles than source files (bundled) and the bundles will be significantly smaller than raw tsc output (minified).
Part 3: tsconfig path aliases
Add path aliases to the Next.js project’s tsconfig.json and verify they work:
{
"compilerOptions": {
"paths": {
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"]
}
}
}
Create src/lib/utils.ts:
export function cn(...classes: string[]): string {
return classes.filter(Boolean).join(' ');
}
Use the alias in a component:
// src/app/page.tsx
import { cn } from '@/lib/utils';
export default function Page() {
return <div className={cn('text-gray-900', 'font-semibold')}>Hello</div>;
}
Run npm run dev and verify the import resolves. Then run npx tsc --noEmit separately to verify type checking also passes. Both must succeed: the bundler resolves imports at build time, and tsc resolves them during type checking.
Quick Reference
| .csproj / MSBuild | tsconfig.json Equivalent | Notes |
|---|---|---|
<TargetFramework>net9.0</TargetFramework> | "target": "ES2022" | Controls JS syntax output, not runtime version |
<LangVersion>13.0</LangVersion> | N/A | TypeScript version is controlled by the typescript package version in package.json |
<Nullable>enable</Nullable> | "strict": true or "strictNullChecks": true | Strict is broader — enables 7 sub-checks |
<OutputPath>bin/</OutputPath> | "outDir": "./dist" | Output directory for compiled JS |
<RootNamespace>MyApp</RootNamespace> | N/A | TypeScript uses file-based modules, not namespaces |
<Optimize>true</Optimize> | Bundler config (Vite/webpack), not tsconfig | Minification is a bundler concern, not tsc |
<Compile Include="..." /> | "include": ["src/**/*"] | File inclusion patterns |
<Compile Remove="..." /> | "exclude": [...] | File exclusion patterns |
| Debug/Release configurations | Multiple tsconfig files + NODE_ENV | No built-in concept; by convention |
Project References <ProjectReference> | "references": [...] in tsconfig + paths | TypeScript project references for monorepos |
dotnet build | tsc + bundler (framework-specific) | Often npm run build invokes both |
dotnet build -v detailed | tsc --listFiles --diagnostics | Verbose compilation output |
dotnet watch build | tsc --watch or vite dev / nest start --watch | Watch mode |
| Bundler command | Equivalent |
|---|---|
next build | Full Next.js production build (type-check + bundle + optimize) |
next dev | Development server with HMR (hot module replacement) |
nest build | NestJS tsc compilation to dist/ |
nest start --watch | NestJS watch mode with auto-restart |
vite build | Vite production bundle |
vite dev | Vite development server |
tsc --noEmit | Type-check only, no output (use in CI) |
tsc --watch | Watch mode, rebuild on changes |
| tsconfig option | What it controls | Recommendation |
|---|---|---|
strict | Enables all strictness sub-flags | Always true |
target | JavaScript syntax in emitted output | ES2022 for Node.js apps; let the framework set it for browser apps |
module | Import/export syntax in emitted output | CommonJS for NestJS; ESNext or let the framework decide for browser |
moduleResolution | How import paths are resolved | NodeNext for modern Node.js; Bundler for Vite/Next.js |
paths | Import path aliases (@/) | Set in tsconfig; also configure in bundler (except Next.js) |
noEmit | Skip file output, type-check only | Use in CI pipelines |
experimentalDecorators | Legacy decorator support | Required for NestJS |
emitDecoratorMetadata | Emit constructor parameter metadata | Required for NestJS DI |
esModuleInterop | Allow import X from 'cjs-module' syntax | Always true |
resolveJsonModule | Allow import config from './config.json' | true when needed |
Further Reading
- TypeScript Compiler Options Reference — The canonical documentation for every tsconfig option. Useful as a reference; do not read linearly.
- Vite Documentation — Getting Started — Covers the development model, configuration, and production build behavior. If you are working on any Vite-based project, read the “Features” and “Build” sections.
- Next.js — TypeScript Configuration — Covers how Next.js reads and extends tsconfig, incremental type checking, and
next buildbehavior. - esbuild Documentation — If you need to understand why modern JS builds are fast, or if you need to write a custom build script, esbuild’s documentation explains the core concepts well even if you are not using it directly.