--noEmit
and Type-Only Builds: Why Your CI Pipeline Should Never Call tsc for Output Again
This article was written with the assistance of AI, under human supervision and review.
Most TypeScript build problems stem from a fundamental confusion: teams treat the TypeScript compiler as both a type-checker and a build tool when it should only ever be the former. The typical CI pipeline runs tsc
to transpile TypeScript into JavaScript, then bundles that output with webpack or Rollup. This sequential flow burns 2-4 minutes per deploy while the type-checker blocks faster tools from doing their job.
The --noEmit
flag separates type-checking from code generation. When configured correctly, your pipeline runs type validation in parallel with a dedicated transpiler like esbuild or swc. The result is 60-80% faster builds with identical type safety. The failure mode here is subtle but expensive: every second your CI spends waiting for tsc
to emit files is a second your deployment sits in a queue.
TypeScript 6.0's native execution model changes this equation entirely. The compiler no longer needs to produce intermediate JavaScript for local development, and modern bundlers strip types directly from .ts
files. This means tsc
can focus exclusively on what it does best: validating types. Production builds never touch the TypeScript compiler's output logic.
That covers why type-only builds matter. The sections below show exactly how to implement this pattern in production CI pipelines, compare the tooling landscape, and migrate legacy build scripts without breaking existing workflows.
--noEmit
flag makes tsc a pure type-checker that never writes files, enabling parallel builds with faster transpilers.tsc --noEmit
in parallel with your bundler, catching type errors without blocking the build..d.ts
) still require tsc for libraries, but application code should never emit them.The --noEmit
compiler option tells TypeScript to perform its full type analysis without writing any output files. This distinction is critical. When you run tsc
without this flag, the compiler does two separate jobs: it validates types and then transpiles your code into JavaScript. These operations happen sequentially even though they have zero logical dependency on each other.
// tsconfig.json
{
"compilerOptions": {
"noEmit": true,
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler"
}
}
With this configuration, running tsc
exits immediately after type-checking. The compiler still reads every file, resolves imports, checks assignability, and reports errors. It simply skips the code generation phase. This shaves 40-60% off execution time because AST traversal for type validation is significantly faster than transformation and file I/O.
The implication here is that your build tool—esbuild, webpack, Rollup, or swc—can start transpiling source files the moment CI begins. It does not wait for TypeScript to finish. Both processes run concurrently, and your build completes when the slower of the two finishes. In practice, transpilation always wins because modern bundlers skip type-checking entirely.
The configuration also enables TypeScript 6.0's moduleResolution: "bundler"
mode, which assumes your bundler handles import resolution. This removes the mismatch between what TypeScript expects and what tools like webpack actually do. Developers no longer see false errors about missing extensions or incompatible module formats.
One caveat applies to library authors: if you publish a package to npm, you need declaration files (.d.ts
). These require tsc
to emit them because no other tool generates TypeScript's type definitions. For libraries, use a separate tsconfig.build.json
with "noEmit": false
and "emitDeclarationOnly": true
. Application code should never touch this configuration.
A production CI pipeline needs two parallel jobs: one runs tsc --noEmit
to validate types, the other runs your bundler to produce deployable artifacts. Both jobs must pass before the pipeline succeeds. This pattern ensures type errors block deployment without slowing down the build itself.
name: CI
on: [push, pull_request]
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run typecheck
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
The typecheck
script in package.json
runs tsc --noEmit
. The build
script runs your bundler with TypeScript stripped but no type validation. Both jobs install dependencies independently, but npm's cache makes this negligible. The critical detail is that they execute simultaneously—GitHub Actions schedules them in parallel by default when they have no dependencies.
// package.json
{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "esbuild src/index.ts --bundle --outdir=dist --platform=node",
"dev": "esbuild src/index.ts --bundle --outdir=dist --watch"
}
}
This matters because the typechecking job typically finishes first (2-3 minutes) while the build job handles bundling, minification, and asset optimization (1-2 minutes). If types fail, the entire pipeline fails immediately without waiting for the build to complete. If types pass but the build fails, you see bundler errors instead of cryptic type mismatches. The error messages stay contextual.
For monorepos, run tsc --noEmit
once at the root with project references configured. Do not run separate type-checks per package—TypeScript already validates the entire workspace in a single pass. The composite project feature resolves cross-package imports without emitting intermediate build artifacts.
One common mistake is running tsc
inside the build script itself. Teams do this because legacy tooling required it, but modern bundlers handle TypeScript natively. Your build
script should invoke webpack, esbuild, or swc directly. The typechecking happens elsewhere.
Three tools dominate TypeScript compilation in 2026, and each serves a distinct purpose. Understanding their tradeoffs determines whether your build is fast, correct, or both.
The TypeScript compiler (tsc
) performs full semantic analysis. It resolves every type, checks assignability across module boundaries, validates generic constraints, and reports errors with precise line numbers. This process requires building a complete type graph of your codebase. Transpilation happens as a side effect after type-checking completes, and it preserves your source's structure almost exactly. The output is readable but slow to produce.
esbuild is a Go-based bundler that strips TypeScript syntax without validating types. It parses your code into an AST, removes type annotations, transforms modern JavaScript to your target, and bundles everything in a single pass. The result is 10-50x faster than tsc
for transpilation, but you get zero type safety. If you pass invalid TypeScript to esbuild, it produces broken JavaScript without warning.
swc (Speedy Web Compiler) is a Rust-based alternative to esbuild with similar performance characteristics. It also strips types without checking them. The advantage over esbuild is better source map quality and more configurable output, but the core limitation is identical: no type validation. Both tools assume you run tsc --noEmit
separately.
The implication here is that you need both a type-checker and a transpiler. You cannot choose one over the other. Teams that skip type-checking because esbuild is faster ship runtime crashes. Teams that use only tsc
wait 4-6 minutes for builds that should take 90 seconds.
One exception applies: if you use Babel with @babel/preset-typescript
, you get the worst of both worlds. Babel strips types slowly and does not check them. The only reason to use Babel in 2026 is for custom syntax transformations that esbuild and swc do not support. Even then, run it after esbuild for performance.
The correct pattern is tsc --noEmit
for type safety plus esbuild or swc for output. This combination gives you sub-2-minute CI builds with full type coverage. The next section shows exactly how to wire this up in production.
A production-grade pipeline runs type-checking and building as independent jobs that must both succeed. The key insight is that neither job depends on the other's output—they read the same source files and produce different artifacts.
Here is a complete GitHub Actions configuration that implements this pattern with caching and failure handling:
name: Deploy
on:
push:
branches: [main]
jobs:
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci --prefer-offline
- run: npm run typecheck
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci --prefer-offline
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
deploy:
needs: [typecheck, build]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
- run: |
echo "Deploying to production"
The needs: [typecheck, build]
directive in the deploy job ensures both checks pass before deployment runs. If types fail, the entire pipeline stops. If the build fails, deployment never triggers. The timeout prevents stuck jobs from blocking your queue indefinitely.
The cache: 'npm'
option in setup-node
reuses node_modules
between runs. This cuts install time from 60 seconds to 5-10 seconds because npm only fetches changed packages. The --prefer-offline
flag tells npm to use cached tarballs when available, which matters for private registries with rate limits.
One critical detail: the typecheck
job does not upload artifacts. It only validates types and exits. The build
job uploads the bundled output, which the deploy
job downloads. This separation keeps artifacts small and avoids storing intermediate files that CI never uses.
For monorepos with multiple packages, run a single typecheck at the root and individual build jobs per package. TypeScript's project references handle cross-package type validation automatically:
// tsconfig.json (root)
{
"compilerOptions": {
"composite": true,
"noEmit": true
},
"references": [
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}
Running tsc --noEmit
at the root validates all packages in dependency order. Each package's build script runs esbuild or swc independently. This pattern scales to 50+ packages without performance degradation because type-checking happens once and builds run in parallel.
The failure mode here is running tsc
without --noEmit
inside the build script. Teams do this because older tutorials suggested it, but it doubles your build time by transpiling code that esbuild already handles. The correct pattern is two separate commands: tsc --noEmit
for types and esbuild
for output.
TypeScript 6.0 introduces native execution mode, which allows running .ts
files directly in Node.js without transpilation. This feature eliminates the need for ts-node
, tsx
, or any other runtime wrapper during development. The TypeScript compiler embeds itself into the Node.js runtime and strips types on-the-fly.
This matters because development workflows no longer need a separate build step. Developers run node --experimental-strip-types src/index.ts
and the file executes immediately. Hot reload tools like nodemon watch for changes and restart without invoking tsc. The feedback loop shrinks from 3-5 seconds to under 500ms.
In other words, the TypeScript compiler's role shifts entirely to CI/CD. Local development uses native execution. Production builds use esbuild or swc. Type-checking happens in CI with tsc --noEmit
. The compiler never emits files except for libraries that need declaration files.
The catch is that native execution does not validate types—it only strips them. If you write invalid TypeScript, Node.js executes the broken JavaScript and crashes at runtime. This is identical to using esbuild or swc locally. The solution is the same: run tsc --noEmit
in watch mode during development.
// package.json
{
"scripts": {
"dev": "concurrently \"tsc --noEmit --watch\" \"node --watch --experimental-strip-types src/index.ts\"",
"build": "esbuild src/index.ts --bundle --outdir=dist",
"typecheck": "tsc --noEmit"
}
}
The concurrently
package runs both commands in parallel. One terminal pane shows type errors as you code. The other pane restarts your server on file changes. Both processes stay fast because they do not block each other.
One limitation applies to decorators and advanced TypeScript features: native execution uses the JavaScript engine's parser, which does not support every TypeScript syntax extension. If your codebase uses experimental features, you still need a transpiler during development. Check the Node.js compatibility matrix before adopting this pattern.
The long-term implication is that tooling complexity decreases dramatically. Teams no longer need ts-node
, tsx
, tsconfig-paths
, or hooks. The runtime handles TypeScript natively. This reduces dependency count, eliminates version conflicts, and makes onboarding new developers faster.
Migrating from a legacy build that uses tsc
for transpilation to a type-only pipeline requires three steps: extract type-checking into a separate script, replace tsc with a faster transpiler, and update your CI configuration.
Start by adding "noEmit": true
to your tsconfig.json
and creating a new typecheck
script in package.json
. Run this script locally to verify it catches the same errors as your current build. Do not change the build script yet.
// tsconfig.json (before)
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"target": "ES2020"
}
}
// tsconfig.json (after)
{
"compilerOptions": {
"noEmit": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler"
}
}
The outDir
and rootDir
options become irrelevant when noEmit
is enabled. Remove them to avoid confusion. The moduleResolution: "bundler"
setting tells TypeScript to trust your bundler's import resolution instead of enforcing its own rules.
Next, replace tsc
in your build script with esbuild or swc. If you currently run tsc && webpack
, change it to just webpack
and configure webpack to use esbuild-
:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.ts$/,
: 'esbuild-',
options: {
target: 'es2020',
},
},
],
},
};
This configuration tells webpack to use esbuild for transpilation. The build stays identical, but execution time drops by 60-70% because esbuild processes files 10x faster than tsc.
Finally, update your CI configuration to run npm run typecheck
as a separate job. The build job no longer calls tsc at all. Both jobs run in parallel, and deployment waits for both to succeed.
jobs:
build:
steps:
- run: npm run build # calls tsc internally
jobs:
typecheck:
steps:
- run: npm run typecheck
build:
steps:
- run: npm run build # uses esbuild only
One common issue during migration: teams discover that their code has type errors that tsc previously ignored because it was configured loosely. Enable strict: true
in tsconfig.json
before adding noEmit
to catch these issues locally. Do not wait for CI to fail.
Another gotcha is path aliases. If your code uses import { foo } from '@/utils'
, you need a plugin to resolve these during bundling. esbuild requires esbuild-plugin-path-alias
. webpack uses tsconfig-paths-webpack-plugin
. swc has built-in support via .swcrc
. Configure this before removing tsc or your builds will break with "module not found" errors.
The payoff is immediate: builds finish faster, CI queues drain faster, and developers see type errors within seconds of saving a file. The type-checking feedback loop becomes as fast as linting.
No—it performs full type analysis but skips code generation. Every type error still reports, and the compiler exits with a non-zero code if types fail. The only difference is that no .js
or .d.ts
files appear in your output directory.
No—esbuild does not validate types. It only strips type annotations and produces JavaScript. You must run tsc --noEmit
separately to catch type errors. This is intentional: esbuild prioritizes speed over correctness.
Use a separate tsconfig.build.json
with "emitDeclarationOnly": true
and "noEmit": false
. Run tsc -p tsconfig.build.json
to generate .d.ts
files alongside your bundled output. Application code should never emit declarations.
Only for local development. You still need tsc --noEmit
in CI to validate types. Native execution strips types without checking them, so runtime crashes can slip through. The correct pattern is native execution during development and parallel type-checking in CI.
Run tsc --noEmit -b
at the root to type-check all packages in dependency order. Each package's build script invokes esbuild or swc independently. TypeScript's composite project feature ensures cross-package types resolve correctly without emitting intermediate files.
That covers the essential patterns for type-only builds in TypeScript 6.0. Apply these in production and your CI pipeline will finish 60-80% faster with identical type safety. The distinction between type-checking and transpilation is no longer optional—modern tooling demands that you separate these concerns or accept slow builds. The failure mode for ignoring this is measurable: every extra minute in CI costs your team deployment velocity and compounds over hundreds of builds per month.