Two things changed quietly in the Node 24 line that make the TypeScript setup on a MEAN API a lot smaller: Node now runs .ts files directly by stripping the types, and the built-in test runner (node:test) is stable enough to replace Jest for API work. Together they remove ts-node, tsx, nodemon, jest, ts-jest and most of a jest.config from a typical Express 5 service.
This tutorial converts a plain Express 5 + Mongoose 8 API to TypeScript that Node executes without a build step, adds integration tests with the native runner and coverage, wires type checking into CI, and then shares the request/response types with the Angular front end. It also covers the parts that genuinely do not work, because type stripping is not a drop-in replacement for tsc in every project.
What "type stripping" actually does
When Node runs node src/server.ts, it does not compile your TypeScript. It erases the type annotations in place, replacing them with whitespace so line and column numbers still match, and hands the resulting JavaScript to V8. No tsconfig.json is read, no type checking happens, and nothing is emitted to disk.
The consequence is the rule that trips everyone up: only erasable syntax is supported. Anything that TypeScript compiles into runtime JavaScript is rejected, because there is nothing to compile it with. That rules out:
enum(use aconstobject plus a union type)- parameter properties (
constructor(private repo: Repo) {}) namespaceblocks with runtime bodies- legacy experimental decorators with
emitDecoratorMetadata
Everything else — interfaces, type aliases, generics, as, satisfies, optional parameters, declare — erases cleanly.
Step 1: prerequisites and project layout
node -v # v24.x or newer
mkdir -p api/src && cd api
npm init -y
npm pkg set type=module
npm install express@5 mongoose@8 zod
npm install -D typescript @types/node @types/express supertest @types/supertest mongodb-memory-server
Node 24 does not need a flag for stripping; --experimental-strip-types was on by default from 22.18 onward and is unflagged in 24. If you are still on Node 22.6–22.17, add the flag or upgrade.
Step 2: a tsconfig that matches how Node runs your code
You still want a tsconfig.json — not to build, but so tsc --noEmit and your editor check the same rules Node will accept at runtime.
{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["es2024"],
"types": ["node"],
"strict": true,
"noEmit": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
Three options do the heavy lifting:
erasableSyntaxOnlymakes the compiler reject enums and parameter properties, so you find out at type-check time instead of when the process refuses to start.verbatimModuleSyntaxforcesimport type { ... }for type-only imports. Without it you can write an import that TypeScript would have elided but Node will try to resolve at runtime.allowImportingTsExtensions+rewriteRelativeImportExtensionslet you writeimport { app } from './app.ts'. Under ESM, Node requires the real file extension, and the real file is a.tsfile. Write.tsin your imports and stop thinking about it.
Step 3: the API in erasable TypeScript
src/models.ts:
import { Schema, model, type InferSchemaType, type HydratedDocument } from 'mongoose';
const taskSchema = new Schema(
{
title: { type: String, required: true, trim: true, maxlength: 200 },
done: { type: Boolean, default: false },
},
{ timestamps: true },
);
export type Task = InferSchemaType<typeof taskSchema>;
export type TaskDoc = HydratedDocument<Task>;
export const TaskModel = model('Task', taskSchema);
InferSchemaType keeps one source of truth: the Mongoose schema. You do not maintain a parallel interface that silently drifts.
src/app.ts — note the import type on the Express types, and that a status enum becomes a const object:
import express, { type Request, type Response, type NextFunction } from 'express';
import { z } from 'zod';
import { TaskModel } from './models.ts';
export const TaskStatus = { Open: 'open', Done: 'done' } as const;
export type TaskStatus = (typeof TaskStatus)[keyof typeof TaskStatus];
const TaskBody = z.object({ title: z.string().min(1).max(200), done: z.boolean().optional() });
export const app = express();
app.use(express.json());
app.get('/api/tasks', async (_req: Request, res: Response) => {
const tasks = await TaskModel.find().sort({ createdAt: -1 }).lean();
res.json(tasks);
});
app.post('/api/tasks', async (req: Request, res: Response) => {
const parsed = TaskBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'invalid body', issues: parsed.error.issues });
return;
}
const task = await TaskModel.create(parsed.data);
res.status(201).json(task);
});
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).json({ error: 'internal error' });
});
src/server.ts:
import mongoose from 'mongoose';
import { app } from './app.ts';
const uri = process.env.MONGODB_URI ?? 'mongodb://localhost:27017/mean-ts';
await mongoose.connect(uri);
const server = app.listen(Number(process.env.PORT ?? 3000), () => {
console.log('api listening');
});
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
server.close(async () => {
await mongoose.disconnect();
process.exit(0);
});
});
}
Run it:
node --watch src/server.ts
That is the whole dev loop. No ts-node/esm loader, no nodemon.json, no dist folder to ignore.
Step 4: integration tests with node:test
The native runner discovers *.test.ts files, runs each file in its own process, and needs no config file. Give it a real MongoDB via mongodb-memory-server so the Mongoose layer is actually exercised.
src/tasks.test.ts:
import { test, before, after, beforeEach, describe } from 'node:test';
import assert from 'node:assert/strict';
import mongoose from 'mongoose';
import request from 'supertest';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { app } from './app.ts';
import { TaskModel } from './models.ts';
let mongod: MongoMemoryServer;
before(async () => {
mongod = await MongoMemoryServer.create();
await mongoose.connect(mongod.getUri());
});
after(async () => {
await mongoose.disconnect();
await mongod.stop();
});
beforeEach(async () => {
await TaskModel.deleteMany({});
});
describe('POST /api/tasks', () => {
test('creates a task', async () => {
const res = await request(app).post('/api/tasks').send({ title: 'ship it' });
assert.equal(res.status, 201);
assert.equal(res.body.title, 'ship it');
assert.equal(await TaskModel.countDocuments(), 1);
});
test('rejects an empty title', async () => {
const res = await request(app).post('/api/tasks').send({ title: '' });
assert.equal(res.status, 400);
assert.equal(res.body.error, 'invalid body');
});
});
Run the suite with coverage:
node --test --experimental-test-coverage "src/**/*.test.ts"
Useful flags in day-to-day work:
node --test --watchre-runs affected files on save.node --test --onlyplustest('...', { only: true })narrows a run.--test-name-pattern="rejects"filters by name.--test-reporter=speclocally,--test-reporter=junit --test-reporter-destination=junit.xmlin CI.
Mocking without Jest
node:test ships its own mocking, including timers:
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import * as mailer from './mailer.ts';
test('sends one welcome email', async () => {
const send = mock.method(mailer, 'send', async () => ({ id: 'stub' }));
await mailer.welcome('dev@example.com');
assert.equal(send.mock.callCount(), 1);
});
Module-level mocking (mock.module()) exists but is still experimental; prefer dependency injection — pass collaborators into your route factories — and you will rarely need it.
Step 5: type checking is now a separate job
This is the trade-off to internalise: running your code no longer checks your types. A file with a real type error runs happily until it hits the bad value. So make checking explicit.
{
"scripts": {
"dev": "node --watch src/server.ts",
"start": "node src/server.ts",
"typecheck": "tsc --noEmit",
"test": "node --test --experimental-test-coverage \"src/**/*.test.ts\"",
"ci": "npm run typecheck && npm test"
}
}
A minimal GitHub Actions job:
jobs:
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '24', cache: 'npm' }
- run: npm ci
- run: npm run typecheck
- run: npm test
Add a pre-commit hook for typecheck if your team is used to the compiler catching mistakes on save.
Step 6: sharing types with the Angular client
The nicest payoff for a MEAN team is that the API's DTOs stop being copy-pasted into the front end. Put them in a shared folder that contains types only:
shared/api-types.ts
export interface TaskDto {
_id: string;
title: string;
done: boolean;
createdAt: string;
updatedAt: string;
}
export type CreateTaskBody = Pick<TaskDto, 'title'> & { done?: boolean };
The API imports it directly (import type { TaskDto } from '../../shared/api-types.ts'). Angular keeps its own build — the Angular CLI compiles TypeScript with tsc/esbuild and is unaffected by Node's stripping — so it imports the same file through a paths entry:
{ "compilerOptions": { "paths": { "@shared/*": ["../shared/*"] } } }
Then in a service:
import { httpResource } from '@angular/common/http';
import type { TaskDto } from '@shared/api-types';
readonly tasks = httpResource<TaskDto[]>(() => '/api/tasks');
One definition, both layers, and a breaking API change now fails the front-end type check.
Gotchas we hit on client projects
pathsaliases do not work on the Node side. Node resolves imports itself and ignorestsconfig. Use relative imports or Node's own subpath imports ("imports": { "#shared/*": "./shared/*" }inpackage.json).- Decorators. Legacy
experimentalDecoratorswith metadata emit are not erasable. Anything built on NestJS-style DI or TypeORM entities still needs a real compile step. Plain Express and Mongoose do not. - Dependencies in
node_modulesare not stripped by default, and you should keep it that way. Publish compiled JavaScript from libraries. - Source maps are free. Because stripping preserves positions, stack traces already point at your
.tslines. If you enable--experimental-transform-types(needed only for enums and parameter properties), positions shift and you want--enable-source-maps. - Production images. Running
.tsin production is supported and we do it, but keep the type check in CI and pin the Node minor in your Dockerfile (FROM node:24-alpine). If you need bundling for cold-start reasons, esbuild still works — nothing here prevents adding a build later. - Coverage thresholds.
--test-coverage-lines=80(and the branch/function variants) fail the run below a threshold, which is the piece most teams miss when they leave Jest.
Is it worth converting an existing API?
Our rule of thumb: if your service is plain Express + Mongoose and its tsconfig exists mainly to satisfy ts-node, converting is usually a half-day and removes four dev dependencies plus a build stage from the pipeline. If it is decorator-heavy, or the build also does bundling, path rewriting and asset copying, keep the compiler and just adopt node:test — the runner is useful on its own.
Migrating a MEAN codebase to Node 24 and modern TypeScript tooling, or untangling a test suite that grew around Jest? Get in touch — our consultants do this kind of upgrade as a fixed-scope engagement.