How to Migrate a Node.js App to TypeScript 5.8 Erasable Syntax
- Upgrade Node.js to 22.6.0+ and TypeScript to 5.8+.
- Enable
erasableSyntaxOnly: trueintsconfig.jsonand runtsc --noEmitto surface all incompatible constructs. - Replace all
enum,const enum, and runtimenamespacedeclarations withas constobjects and ES module exports. - Rewrite constructor parameter properties as explicit property declarations and assignments.
- Enforce
verbatimModuleSyntax: trueand convert ambiguous imports toimport type. - Update
package.jsonscripts to run.tsfiles directly vianode --experimental-strip-types. - Simplify your Dockerfile to a single stage, removing the build/dist pipeline.
- Verify that
tsc --noEmitruns in CI as the sole type-safety gate before deployment.
Running TypeScript directly in Node.js without a transpilation step has shifted from experiment to officially supported workflow. TypeScript 5.8 introduced the erasableSyntaxOnly compiler option, and Node.js 22.6.0+ ships with built-in type stripping via --experimental-strip-types, creating the first viable path to executing .ts files in production Node.js environments. This article walks through a complete production migration, covering the mechanics of type stripping, the required configuration, concrete code transformations for incompatible patterns, and the CI/CD adjustments necessary to eliminate the build step while preserving type safety.
Table of Contents
- What Changed: TypeScript 5.8 and Node.js Type Stripping
- Why This Matters for Production Node.js Applications
- Prerequisites and Compatibility Matrix
- Step 1: Audit Your Codebase for Non-Erasable Syntax
- Step 2: Configure tsconfig.json for Erasable-Only Compilation
- Step 3: Update Your Project Entry Point and Scripts
- Step 4: Adapt Your CI/CD Pipeline and Docker Setup
- Step 5: Handle Edge Cases and Third-Party Dependencies
- Production Migration Checklist
- Performance and Trade-offs in Practice
- Summary and Next Steps
What Changed: TypeScript 5.8 and Node.js Type Stripping
How Node.js Type Stripping Works Under the Hood
Node.js integrates a package called amaro, which is built on top of SWC, the Rust-based JavaScript/TypeScript compiler. When Node.js loads a .ts file, it uses amaro to strip type annotations from the source before executing it. This distinction matters because Node.js does not perform type-checking, and it does not transform or downlevel any runtime semantics. It removes type syntax and runs the remaining JavaScript as-is.
The operation is purely syntactic. It does not infer types, transform emitted output, or convert module formats. The JavaScript engine runs exactly what the developer wrote, minus the type annotations. This means constructs that exist only in the type system (interfaces, type aliases, type annotations on parameters and return values) vanish cleanly. But constructs that carry runtime semantics beyond their type information cannot simply be deleted.
The erasableSyntaxOnly Flag in TypeScript 5.8
TypeScript 5.8 introduced erasableSyntaxOnly as a compiler option that enforces a strict constraint: every TypeScript-specific syntax construct in the codebase must be removable without altering runtime behavior. "Erasable syntax" is anything that can be deleted from the source and leave behind valid, semantically identical JavaScript.
Erasable constructs:
- Type annotations on variables, parameters, and return types
interfaceandtypealias declarations- Function overload signatures
declareblocks- Generic type parameters
Non-erasable constructs:
enumdeclarations (non-const) — they generate runtime objectsconst enumdeclarations — per-file stripping cannot inline values across filesnamespaceblocks containing runtime code — they produce IIFEs or object assignments- Constructor parameter properties (e.g.,
constructor(private name: string)) — they implicitly generate property declarations and assignments - Legacy
import = require()syntax — it requires module system transformation
When erasableSyntaxOnly is enabled, the TypeScript compiler reports errors on every non-erasable construct, giving teams a full audit of everything that must change before Node.js type stripping can work.
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "esnext"
}
}
The verbatimModuleSyntax option works alongside erasableSyntaxOnly by requiring that all type-only imports use the explicit import type syntax. This ensures the type stripper knows which imports to remove and which to preserve.
Why This Matters for Production Node.js Applications
Eliminating the Build Step
Removing the transpilation step from a Node.js application has cascading effects across the development and deployment lifecycle. CI/CD pipelines no longer need a tsc compilation stage before deployment, and deployment artifacts become the source files themselves, eliminating the dist/ directory and the mapping between source and output that teams have maintained for years. You can replace multi-stage Docker builds with a single-stage copy.
CI/CD pipelines no longer need a
tsccompilation stage before deployment, and deployment artifacts become the source files themselves, eliminating thedist/directory and the mapping between source and output that teams have maintained for years.
For teams running microservices or serverless functions on Node.js 22.6+, this eliminates the compilation wait on each change. You test against the source directly — no source-map configuration to maintain, no debugging discrepancies between source and emitted JavaScript.
When This Approach Is NOT Appropriate
If your project targets Node.js < 22.6, type stripping is unavailable. Frontend applications that bundle code for browser delivery still require a bundler and typically need downleveling to older ECMAScript targets. Projects that rely heavily on enum, const enum, or namespace blocks with runtime code will need to rewrite every such declaration before adopting erasable-only syntax — run tsc --noEmit with erasableSyntaxOnly to get the error count and gauge the scope. Library authors who publish npm packages still need tsc to generate .d.ts declaration files and compiled JavaScript for consumers on arbitrary Node.js versions.
Prerequisites and Compatibility Matrix
Required Versions and Runtime Flags
The following matrix captures the version requirements and their corresponding runtime configurations:
| Node.js Version | Flag Required | TypeScript Version | Base Config |
|---|---|---|---|
| 22.6.0–23.x | --experimental-strip-types |
5.8+ | @tsconfig/node22 or equivalent |
| 24.0+ | None (verify at release) | 5.8+ | @tsconfig/node24 or equivalent |
| < 22.6 | Not supported | N/A | N/A |
Node.js 22.6.0 introduced --experimental-strip-types as a runtime flag. Node.js 24+ is expected to ship type stripping as unflagged functionality; verify this against the official Node.js 24 release notes before removing the flag in production. TypeScript 5.8 is the minimum version that provides the erasableSyntaxOnly compiler option.
Step 1: Audit Your Codebase for Non-Erasable Syntax
Enabling erasableSyntaxOnly to Surface Errors
The first step in migration is adding "erasableSyntaxOnly": true to tsconfig.json and running the compiler in check-only mode. This surfaces every construct that Node.js type stripping cannot handle.
$ npx tsc --noEmit
src/constants.ts(3,1): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
enum Status { Active, Inactive, Pending }
src/services/user.ts(8,3): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
constructor(private readonly db: Database) {}
src/utils/math.ts(1,1): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
namespace MathUtils { export function add(a: number, b: number) { return a + b; } }
Found 3 errors.
Each error identifies the exact file, line, and construct that must be replaced. The error code TS1294 is specific to erasable syntax violations.
Common Non-Erasable Patterns and Their Replacements
Replacing enum and const enum with as const Objects
TypeScript enums compile to runtime objects with both forward and reverse mappings. The as const pattern produces an equivalent forward-mapping runtime object without requiring any transformation.
Before:
enum Status {
Active = "ACTIVE",
Inactive = "INACTIVE",
Pending = "PENDING",
}
function getLabel(status: Status): string {
return status.toLowerCase();
}
After:
const Status = {
Active: "ACTIVE",
Inactive: "INACTIVE",
Pending: "PENDING",
} as const;
type Status = (typeof Status)[keyof typeof Status];
/**
* Reverse lookup map. Use this instead of Status["ACTIVE"] —
* reverse mapping is NOT available on `as const` objects.
* If your code performed reverse lookups on the original enum
* (e.g., Status[value] to get the key name), use this map instead.
*/
const StatusByValue = Object.fromEntries(
Object.entries(Status).map(([k, v]) => [v, k])
) as { [V in Status]: keyof typeof Status };
function getLabel(status: Status): string {
return status.toLowerCase();
}
The as const assertion ensures the object's values are literal types rather than widened strings. The derived Status type union ("ACTIVE" | "INACTIVE" | "PENDING") provides the same type-safety as the original enum. At runtime, Status.Active resolves to "ACTIVE" identically in both versions.
Important: Unlike TypeScript enums,
as constobjects do not provide reverse mappings. If your original enum code used reverse lookups likeStatus[someValue]to retrieve the key name from a value, this will silently returnundefinedat runtime after migration — TypeScript will not flag this as an error.
Note: const enum is also incompatible with this workflow. With verbatimModuleSyntax: true (which implies isolatedModules), const enum declarations are forbidden because isolated per-file compilation cannot inline values across file boundaries. Replace const enum with as const objects using the same pattern shown above.
Replacing Constructor Parameter Properties
Parameter properties are syntactic sugar that TypeScript expands into property declarations and constructor assignments during compilation. Since type stripping does not perform this expansion, the explicit form is required.
Before:
class UserService {
constructor(
private readonly db: Database,
private readonly logger: Logger
) {}
}
After:
class UserService {
private readonly db: Database;
private readonly logger: Logger;
constructor(db: Database, logger: Logger) {
this.db = db;
this.logger = logger;
}
}
The private and readonly modifiers on the property declarations are themselves type-only annotations and are erasable. What matters is that the assignment this.db = db is now explicit JavaScript that survives stripping.
Replacing namespace with ES Modules
Namespaces that contain runtime code (functions, classes, variables) generate JavaScript wrapper objects. Replacing them with standard ES module exports is straightforward.
Before:
namespace MathUtils {
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
}
After (in a file such as math-utils.ts):
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
Consumers change from MathUtils.add(1, 2) to import { add } from "./math-utils.ts". Note that with Node.js type stripping, import specifiers should use the .ts extension, since the file is loaded directly.
Step 2: Configure tsconfig.json for Erasable-Only Compilation
Recommended Compiler Options
A complete production tsconfig.json for a Node.js 22.6+ application using type stripping:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"strict": true,
"noEmit": true,
"skipLibCheck": false,
"esModuleInterop": true
},
"include": ["src/**/*.ts"]
}
target: "esnext" prevents TypeScript from downleveling any syntax, since Node.js 22.6+ supports modern ECMAScript features natively. module: "nodenext" and moduleResolution: "nodenext" align module behavior with Node.js's native module resolution. noEmit: true reflects the reality that tsc now only type-checks, never compiles. verbatimModuleSyntax: true implies isolatedModules in TypeScript 5.x, enforcing that each file can be type-checked independently, consistent with how SWC-based stripping processes files. skipLibCheck: false ensures tsc catches type errors in consumed .d.ts files — this matters because type stripping removes the build step, making tsc --noEmit the sole type-safety gate. If specific upstream packages have broken types that block builds, use per-package overrides via typeRoots or patch the package with patch-package, rather than disabling all .d.ts checking globally. esModuleInterop: true ensures that default imports from CommonJS modules (e.g., import fs from 'node:fs') work correctly at runtime.
Handling import type and export type Correctly
With verbatimModuleSyntax enabled, TypeScript requires explicit disambiguation between value imports and type imports. This is not optional for type stripping, because without explicit import type, SWC must infer which imports are type-only. verbatimModuleSyntax enforces explicit disambiguation at the TypeScript level, eliminating reliance on SWC's inference.
Before:
import { UserService, UserType } from "./user-service.ts";
If UserType is only a type and UserService is a runtime class, this import is ambiguous.
After:
import { UserService } from "./user-service.ts";
import type { UserType } from "./user-service.ts";
Alternatively, using inline type qualifiers:
import { UserService, type UserType } from "./user-service.ts";
Both forms make the intent explicit. The stripper removes import type statements entirely and preserves the value import.
Step 3: Update Your Project Entry Point and Scripts
Running .ts Files Directly with Node.js
The package.json scripts section changes to invoke .ts files directly:
{
"scripts": {
"start": "node --experimental-strip-types src/index.ts",
"dev": "node --watch --experimental-strip-types src/index.ts",
"typecheck": "tsc --noEmit",
"test": "node --experimental-strip-types --test src/**/*.test.ts"
}
}
Note: The glob pattern src/**/*.test.ts in the test script is unquoted so that the shell expands it before passing the file list to Node.js. On bash, this requires globstar to be enabled (shopt -s globstar). On Windows (cmd or PowerShell), ** globs are not expanded by the shell, and on some shells a glob matching zero files may cause Node.js to exit successfully with no tests run. If your CI runs on Windows or a shell without globstar, list test files explicitly or use a cross-platform glob tool such as the glob CLI package (npm i -D glob) to avoid silently running zero tests:
{
"test": "glob -c 'node --experimental-strip-types --test' 'src/**/*.test.ts'"
}
On Node.js 24+, the --experimental-strip-types flag can be removed entirely (verify against official release notes). The typecheck script remains because type stripping does not validate types. Running tsc --noEmit is the type-checking gate.
Note: The --watch mode in the dev script provides hot reload but does not re-run type checking. Use a separate terminal with tsc --noEmit --watch for live type feedback during development.
File Extensions: .ts vs .mts vs .cts
When package.json contains "type": "module", .ts files are treated as ES modules. For CommonJS modules in an ESM package, use .cts. For explicit ESM in a CommonJS package, use .mts. These conventions mirror the .mjs/.cjs conventions that Node.js already uses for JavaScript files. Most projects setting "type": "module" can use .ts exclusively.
Step 4: Adapt Your CI/CD Pipeline and Docker Setup
Simplified Dockerfile Without a Build Stage
Before (multi-stage build):
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src/ ./src/
RUN npx tsc
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY /app/dist/ ./dist/
CMD ["node", "dist/index.js"]
After (single-stage):
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
# tsconfig.json intentionally excluded: type checking runs in CI, not at runtime.
# @types/* packages are devDependencies and are also excluded; tsc --noEmit
# cannot run inside this image. Add tsconfig.json to .dockerignore to make this explicit.
COPY src/ ./src/
# Run as non-root user (the 'node' user is built into node:*-slim images)
USER node
CMD ["node", "--experimental-strip-types", "src/index.ts"]
The builder stage, the tsconfig.json copy, the tsc invocation, and the dist/ directory are all eliminated. The production image contains only source files and production dependencies. The tsconfig.json is no longer needed in the production image because type checking runs in CI, not at deploy time.
Ensure a .dockerignore file excludes test fixtures, .env files, and any non-production source. For example, add src/**/*.test.ts and src/**/*.spec.ts to .dockerignore to keep test files out of the production image.
CI Pipeline Adjustments
CI must run tsc --noEmit to catch type errors before deployment, since Node.js type stripping provides zero type safety at runtime. Linting and test execution remain unchanged, though test runner invocations may need the --experimental-strip-types flag if tests are written in .ts files.
Step 5: Handle Edge Cases and Third-Party Dependencies
Importing from JavaScript Dependencies
No changes are required for JavaScript dependencies. They continue to work exactly as before. The node_modules directory contains JavaScript, and Node.js loads it through its standard module resolution.
Consuming .d.ts Declaration Files
Library authors who publish npm packages must still use tsc to generate .d.ts declaration files and compiled JavaScript output. Type stripping is an application-level workflow. Published packages need to ship JavaScript and type declarations for consumers who may be running any Node.js version with any TypeScript configuration.
Decorators and experimentalDecorators
TC39 decorators (stage 3 as of late 2024; verify current stage at github.com/tc39/proposal-decorators), which TypeScript supports natively in recent versions, are erasable-safe because they use standard JavaScript syntax. Legacy decorators enabled via the experimentalDecorators compiler flag require transformation during compilation and are not compatible with type stripping. Projects using frameworks that depend on legacy decorators (such as older versions of NestJS or TypeORM with legacy decorator configuration) will need to either migrate to TC39 decorators or retain a build step.
Production Migration Checklist
Production Migration Checklist: TypeScript 5.8 + Node.js 22.6+ Type Stripping
===============================================================================
1. [ ] Verify Node.js version >= 22.6.0 (`node --version`)
2. [ ] Install TypeScript >= 5.8 (`npx tsc --version`)
3. [ ] Enable `erasableSyntaxOnly: true` in tsconfig.json
4. [ ] Run `tsc --noEmit` and catalog all TS1294 violations
5. [ ] Replace all `enum` and `const enum` declarations with `as const` objects
6. [ ] Verify no reverse-mapping lookups exist for migrated enums (add reverse maps if needed)
7. [ ] Replace constructor parameter properties with explicit declarations
8. [ ] Replace `namespace` blocks with ES module exports
9. [ ] Enable `verbatimModuleSyntax: true` and fix all import/export types
10. [ ] Update package.json scripts to run .ts files directly
11. [ ] Update Dockerfile to single-stage (remove build/dist stages)
12. [ ] Confirm `tsc --noEmit` passes in CI as the type-check gate
13. [ ] Remove old build artifacts, dist/ directory, and stale tsconfig emit options
Performance and Trade-offs in Practice
Startup Time Considerations
SWC's Rust-based parser strips TypeScript syntax at native speed, and for a typical project with tens to low hundreds of source files, the per-file overhead compared to running pre-compiled .js files is hard to measure without profiling. To quantify the cost in your project, compare time node --experimental-strip-types src/index.ts against time node dist/index.js (from a pre-compiled build). For serverless workloads where cold start latency matters, profile the aggregate stripping time across all loaded modules with --cpu-prof — especially if your project exceeds a few hundred .ts files.
Debugging and Source Maps
Node.js type stripping supports source maps when you pass the --enable-source-maps flag. The amaro package emits inline source maps by default during stripping, so stack traces in error output map back to the original .ts file names and line numbers. Verify this in your environment by running node --enable-source-maps --experimental-strip-types src/index.ts and confirming that .ts line numbers appear in error stack traces. This matters for production error tracking, where stack traces need to reference the actual source code developers work with.
Node.js type stripping supports source maps when you pass the
--enable-source-mapsflag. Theamaropackage emits inline source maps by default during stripping, so stack traces in error output map back to the original.tsfile names and line numbers.
Summary and Next Steps
The migration path follows a sequence: audit the codebase for non-erasable syntax using tsc --noEmit with erasableSyntaxOnly, then refactor enums (including const enum), parameter properties, and namespaces into erasable alternatives. From there, configure tsconfig.json for type-check-only mode with verbatimModuleSyntax, update entry points and scripts to run .ts files directly, and simplify deployment infrastructure by removing build stages.
This approach is best suited for application code running on modern Node.js (22.6+). Library publishing still requires tsc for declaration file generation and JavaScript output. Teams should consult the official TypeScript 5.8 release notes and the Node.js documentation on type stripping for updates as Node.js 24+ stabilizes unflagged support.

