🚀 Migrating to Native
TypeScript Execution

No build step. No ts-node. Just node file.ts

Node 24 · A step-by-step walkthrough

Agenda

  1. The starting point — a typical TS project
  2. Try node src/index.ts — watch it break
  3. Fix errors one by one (6 errors, 6 fixes)
  4. Update tooling (tsconfig, Vitest, Docker)
  5. Lessons learned from a ~700 file codebase

What changed in Node?

  • Node 22.6--experimental-strip-types
  • Node 23.6 — Type stripping enabled by default
  • Node 24 — Stable, no flags needed


Node erases type annotations and runs the JS that's left.
No transpilation. No emit. Just strip and execute.

What it can't strip

Anything that generates runtime code:

  • enum declarations
  • Constructor parameter properties
  • Legacy import x = require()


These aren't "erasable" — they change the output JS.

The starting project

  • Express + Sequelize + SQLite
  • Mixed .ts and .js files
  • CommonJS (require / module.exports)
  • TypeScript enums
  • Import hoisting
  • ts-node for development, tsc build for production
  • Jest + ts-jest for tests
  • dist/ → multi-stage Dockerfile

Hidden helper: tsx

tsx = drop-in replacement for ts-node

  • Uses esbuild under the hood — startup: ~12s → ~6s
  • ESM support works out of the box
  • Just runs the code — no type checking
  • Bare imports (from "./models") just work

Great quick win. But still a runtime dependency.
Native execution means zero extra tooling.

The import hoisting trap

What you write

process.env.DB_PATH = ":memory:";

import config from "./config.js";

What ESM executes

import config from "./config.js";

process.env.DB_PATH = ":memory:";  // too late!

ESM hoists all import declarations.
config.js loads before DB_PATH is set → wrong value.

Step 1: Just try it

"start": "node src/index.ts"
ERR_UNSUPPORTED_DIR_IMPORT
Directory import '.../src/models' is not supported

ESM requires explicit file extensions on imports.
import from "./models" → not allowed.

Step 2: Add "type": "module"

Maybe we need to tell Node it's ESM?

ERR_UNSUPPORTED_DIR_IMPORT
(same error)

Node already detected import syntax.
The problem is bare imports, not the module type.

Step 3: Add file extensions to imports

// Before
import { User } from "./models";
import { Role } from "../types";

// After
import { User } from "./models/index.ts";
import { Role } from "../types.ts";
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX
TypeScript enum declaration is not supported

Step 4: Convert enums to as const

Before

enum Role {
  Admin = "admin",
  User = "user",
}

After

const Role = {
  Admin: "admin",
  User: "user",
} as const;

type Role =
  (typeof Role)[keyof typeof Role];

Role.Admin still works. Types still infer correctly.

SyntaxError: 'sequelize' does not provide
an export named 'CreationOptional'

Step 5: Split type-only imports

// Before — all named imports together
import { Model, DataTypes, CreationOptional } from "sequelize";

// After — runtime values vs types
import { Model, DataTypes } from "sequelize";
import type { CreationOptional, ForeignKey } from "sequelize";

CJS packages can't provide TypeScript types as ESM exports at runtime.
import type is erased — it never reaches the module loader.

ReferenceError: require is not defined in ES module scope

Step 6: Replace require() with import

// Before
const config = require("./config.js");

// After
import config from "./config.js";

Also convert JS files from module.exports to ESM export.

[config] DB_PATH at load time: (not set, using :memory:)

Import hoisting changed the execution order.

Why import hoisting breaks things

  • CJS: require() runs inline, top to bottom
  • ESM: import is hoisted above all executable code

Anything that must run before imports is silently broken:
env setup, error handlers, dotenv.config()

Fix: Move setup to a side-effect import:
import "./setup.ts"; — hoisted too, but evaluated first.

⚠️ ESM pitfalls to watch for

  • Circular model imports
  • Module-level initialization
  • Temporal Dead Zone

⚠️ Circular model imports

Associations that reference other models cause TDZ errors.

Fix: Extract to a central file:

// src/models/associations.ts
import { User } from "./User.ts";
import { Post } from "./Post.ts";
import { Comment } from "./Comment.ts";

User.hasMany(Post, { foreignKey: "userId", as: "posts" });
Post.belongsTo(User, { foreignKey: "userId", as: "author" });
User.hasMany(Comment, { foreignKey: "userId", as: "comments" });

Individual model files use import type for cross-model refs.
No circular runtime imports → no TDZ.

⚠️ Module-level initialization

Top-level code runs during import resolution,
before your entry point code executes.

// User.ts — runs as soon as User.ts is imported
User.init(
    { name: { type: DataTypes.STRING } },
    { sequelize, modelName: "User" }
);

// If sequelize isn't ready yet → crash

Fix: Ensure dependencies like the DB connection
are initialized in a module imported before the models.

⚠️ Temporal Dead Zone

ESM bindings exist but are uninitialized until
the module body runs.

// Comment.ts
import { User } from "./User.ts";

// If User.ts hasn't finished executing:
User.hasMany(Comment, { foreignKey: "userId" });
//   ↑ ReferenceError: Cannot access 'User'
//     before initialization

CJS returns undefined for unfinished exports.
ESM throws a ReferenceError — fail loud, fail early.

Step 7: Update tsconfig

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true
  }
}
  • noEmit — tsc is now a type-checker only
  • allowImportingTsExtensions.ts in import paths OK
  • erasableSyntaxOnly — catches non-strippable syntax
npm run build passes clean.

Step 8: Jest → Vitest

Jest can't load jest.config.js in an ESM package.

npm uninstall jest ts-jest @types/jest
npm install -D vitest
  • Delete jest.config.js, tsconfig.test.json
  • Add vitest.config.ts
  • Fix test imports (add .ts extensions)
16/16 tests passing.

Step 9: Simplify Dockerfile

Before

FROM node:24-slim AS build
COPY . .
RUN npm ci && npm run build

FROM node:24-slim
COPY --from=build /app/dist .
CMD ["node", "dist/index.js"]

After

FROM node:24-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
CMD ["node", "src/index.ts"]

No build stage. Ship source. Run directly.

The migration in 9 steps

  1. node src/index.ts → bare directory import error
  2. "type": "module" → same error
  3. Add .ts extensions → enum error
  4. as const objects → CJS type export error
  5. import type → require not defined
  6. import + ESM exports → import hoisting breaks env setup
  7. Update tsconfig → build passes
  8. Jest → Vitest → tests pass
  9. Simplify Dockerfile → ship it

Lessons from ~700 files

  • Import hoisting is the hard part — everything else is mechanical
  • A regex script handled ~4700 imports for extensions
  • import type isn't just good practice — it's required for CJS packages in ESM
  • No ESM config exists to auto-resolve .ts extensions — you must be explicit

What you get

  • 🗑️ No build step
  • 🗑️ No ts-node / tsx
  • 🗑️ No source maps to debug
  • 🔥 Faster startup (~3s)
  • 🐳 Simpler Docker images
  • ✅ Native Node — zero runtime dependencies for TS

Thanks!

Code: github.com/horiaradu/ts-migration-demo

Every step is a commit. Walk through the git log.