node file.tsNode 24 · A step-by-step walkthrough
node src/index.ts — watch it break--experimental-strip-typesNode erases type annotations and runs the JS that's left.
No transpilation. No emit. Just strip and execute.
Anything that generates runtime code:
enum declarationsimport x = require()These aren't "erasable" — they change the output JS.
.ts and .js filesrequire / module.exports)ts-node for development, tsc build for productiondist/ → multi-stage Dockerfiletsx = drop-in replacement for ts-node
from "./models") just workGreat quick win. But still a runtime dependency.
Native execution means zero extra tooling.
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.
"start": "node src/index.ts"
ESM requires explicit file extensions on imports.
import from "./models" → not allowed.
"type": "module"Maybe we need to tell Node it's ESM?
Node already detected import syntax.
The problem is bare imports, not the module type.
// Before
import { User } from "./models";
import { Role } from "../types";
// After
import { User } from "./models/index.ts";
import { Role } from "../types.ts";
as constBefore
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.
// 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.
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.
Import hoisting changed the execution order.
require() runs inline, top to bottomimport is hoisted above all executable codeAnything 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.
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.
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.
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.
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true
}
}
noEmit — tsc is now a type-checker onlyallowImportingTsExtensions — .ts in import paths OKerasableSyntaxOnly — catches non-strippable syntaxJest can't load jest.config.js in an ESM package.
npm uninstall jest ts-jest @types/jest
npm install -D vitest
jest.config.js, tsconfig.test.jsonvitest.config.ts.ts extensions)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.
node src/index.ts → bare directory import error"type": "module" → same error.ts extensions → enum erroras const objects → CJS type export errorimport type → require not definedimport + ESM exports → import hoisting breaks env setupimport type isn't just good practice — it's required for CJS packages in ESM.ts extensions — you must be explicitCode: github.com/horiaradu/ts-migration-demo
Every step is a commit. Walk through the git log.