écrits/tutorial/2026/08
Tutorial3 août 2026·28 min

TypeScript natif dans Node.js 26 : déployez une API de production sans étape de build

Node.js 26 exécute TypeScript directement — sans tsc, sans bundler, sans dossier dist. Construisez une API REST complète avec node:http, node:sqlite et node:test, découvrez les six règles de syntaxe qui casseront votre code, et déployez le tout dans une image Docker à une seule étape.

Pendant une décennie, exécuter du TypeScript sur un serveur signifiait accepter une étape de build. Vous installiez typescript, configuriez tsc, produisiez un dossier dist/, puis passiez une part étonnante de votre vie à comprendre pourquoi ce que vous exécutiez n'était pas ce que vous aviez écrit. Ou bien vous vous tourniez vers ts-node, puis tsx, échangeant le temps de build contre du temps de démarrage à froid.

Node.js 26 met fin à cet arrangement. Le retrait des types (type stripping) est stable et activé par défaut — node server.ts fonctionne, tout simplement. Pas de loader, pas de drapeau, pas de compilateur.

Mais ce « ça marche tout seul » cache une arête vive. Node.js 26 a également supprimé entièrement le drapeau --experimental-transform-types. Si votre code utilise des enums, des décorateurs ou des propriétés de paramètres, et que vous comptiez sur Node pour les transformer, ce code ne s'exécute plus. Ce tutoriel vous enseigne les deux moitiés : comment construire un vrai service de production sans étape de build, et exactement quelles fonctionnalités TypeScript vous devez abandonner pour y parvenir.

Prérequis

Avant de commencer, assurez-vous de disposer de :

  • Node.js 26.0.0 ou plus récent (vérifiez avec node --version)
  • TypeScript 5.8+ en dépendance de développement pour la vérification de types
  • Une aisance avec les modules ES, async/await et les concepts REST de base
  • Un éditeur avec support TypeScript (VS Code recommandé)
  • Docker installé, si vous souhaitez suivre l'étape de déploiement

Vous n'avez pas besoin de : ts-node, tsx, nodemon, d'un bundler ni d'un dossier dist/.

Ce que vous allez construire

Une API REST complète de gestion de tâches avec :

  • Des points de terminaison CRUD sur node:http seul — sans Express, sans Fastify
  • Une persistance via node:sqlite, le pilote SQLite intégré
  • Une validation des requêtes à l'exécution écrite sous forme de fonctions ordinaires
  • Une suite de tests tournant sur node --test
  • Une vérification de types imposée en CI comme barrière distincte
  • Une image Docker à une seule étape, sans aucun artefact de build

Le service final possède zéro dépendance d'exécution. La seule chose dans node_modules est TypeScript lui-même, et il ne s'exécute jamais en production.

Étape 1 : comprendre ce qui a réellement changé

Avant d'écrire du code, ajustez votre modèle mental. Node ne compile pas votre TypeScript : il l'efface.

Lorsque Node charge un fichier .ts, un module nommé amaro (une fine enveloppe autour de l'analyseur SWC) retire chaque annotation de type et la remplace par des espaces blancs. Les numéros de ligne et les décalages de colonnes restent identiques octet pour octet, raison pour laquelle vous n'avez pas besoin de source maps — une trace d'appel pointe déjà sur la bonne ligne de votre fichier d'origine.

Vérifiez votre installation :

node --version
# v26.0.0 ou plus récent

Créez un fichier d'essai pour confirmer que le retrait fonctionne :

// scratch.ts
type Greeting = { name: string; formal: boolean };
 
function greet({ name, formal }: Greeting): string {
  return formal ? `Good evening, ${name}.` : `Hey ${name}!`;
}
 
console.log(greet({ name: "Amira", formal: true }));

Exécutez-le directement :

node scratch.ts
# Good evening, Amira.

Aucun drapeau. Aucune configuration. C'est toute la fonctionnalité.

Deux corollaires importants découlent du principe « effacer, pas compiler » :

Node n'effectue absolument aucune vérification de types. Le runtime exécutera volontiers du code aux types totalement cassés. const x: number = "hello" s'exécute sans broncher. La sûreté des types devient une affaire de CI, plus une affaire d'exécution — nous traitons cela à l'étape 9.

Tout ce qui nécessite du code généré à l'exécution est impossible. Un enum n'est pas seulement un type ; il se compile en un véritable objet JavaScript. Il n'y a rien à effacer, donc Node refuse. C'est la source de tous les maux de tête de migration ci-dessous.

La chronologie, pour savoir à quoi vous attendre sur les runtimes plus anciens

Version de NodeStatut du retrait des types
22.xDerrière --experimental-strip-types
23.xSans drapeau, mais expérimental
24.xPar défaut pour les fichiers .ts
25.2+Marqué stable
26.xStable, et --experimental-transform-types supprimé

Si vous devez le désactiver pour une raison quelconque, le drapeau est --no-strip-types.

Étape 2 : mise en place du projet

Créez le squelette du projet :

mkdir task-api && cd task-api
npm init -y
npm install --save-dev typescript @types/node

Voilà toute la liste des dépendances. Modifiez maintenant package.json :

{
  "name": "task-api",
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=26.0.0"
  },
  "scripts": {
    "dev": "node --watch --env-file-if-exists=.env src/server.ts",
    "start": "node --env-file-if-exists=.env src/server.ts",
    "test": "node --test 'src/**/*.test.ts'",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "typescript": "^5.8.0",
    "@types/node": "^26.0.0"
  }
}

Notez "type": "module". Le retrait natif des types fonctionne au mieux avec les modules ES, et tout l'écosystème a migré dans cette direction. Notez également que dev et start pointent vers le même fichier TypeScript — il n'existe pas de point d'entrée de production distinct.

Le tsconfig qui compte

C'est le fichier le plus important du projet. Ratez-le et TypeScript acceptera avec plaisir du code que Node refusera d'exécuter.

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["esnext"],
    "types": ["node"],
 
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "rewriteRelativeImportExtensions": true,
    "allowImportingTsExtensions": true,
 
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

Quatre de ces options existent précisément pour vous maintenir honnête :

erasableSyntaxOnly est la vedette. Elle fait rejeter par tsc toute syntaxe que Node ne sait pas retirer — enums, espaces de noms comportant des membres à l'exécution, propriétés de paramètres. Sans elle, vous le découvrez à l'exécution, en production. Avec elle, vous le découvrez dans votre éditeur.

verbatimModuleSyntax vous force à écrire import type explicitement. Ce n'est pas une question de style. L'outil de retrait de Node est un analyseur, pas un vérificateur de types — il n'a aucune idée de si Task dans import { Task } from './types.ts' est un type ou une valeur. Si c'est un type et que vous ne l'avez pas précisé, Node émet un véritable import à l'exécution, ne trouve aucun export de ce nom, et lève une erreur.

rewriteRelativeImportExtensions et allowImportingTsExtensions vous permettent d'écrire .ts dans les chemins d'import, ce que Node exige.

noEmit: true parce que tsc ne produit ici aucune sortie. C'est devenu un linter.

Étape 3 : les six règles qui casseront votre code

Toute migration vers le retrait natif des types se heurte aux six mêmes murs. Apprenez-les avant d'écrire une ligne.

Règle 1 : pas d'enums

// CASSÉ — Node lève une SyntaxError
enum Status {
  Pending = "pending",
  Done = "done"
}

Un enum génère un objet à l'exécution. Utilisez plutôt un objet const avec un type dérivé — il est plus léger, compatible avec le tree-shaking, et produit de meilleurs messages d'erreur :

// src/types.ts
export const Status = {
  Pending: "pending",
  Done: "done",
  Archived: "archived"
} as const;
 
export type Status = (typeof Status)[keyof typeof Status];
// type Status = "pending" | "done" | "archived"

Vous l'utilisez de manière quasi identique : Status.Pending pour la valeur, Status pour le type.

Règle 2 : extensions .ts explicites sur chaque import relatif

import { createTaskStore } from "./store";     // CASSÉ
import { createTaskStore } from "./store.ts";  // Correct

C'est le piège de tous ceux qui viennent d'un bundler. La résolution de modules de Node ne devine pas les extensions.

Règle 3 : import type est obligatoire pour les types

// CASSÉ à l'exécution — Node émet un vrai import pour un export uniquement typé
import { Task } from "./types.ts";
 
// Correct
import type { Task } from "./types.ts";
 
// Correct — import mixte avec marqueur de type en ligne
import { Status, type Task } from "./types.ts";

verbatimModuleSyntax détecte cela au moment de la vérification de types.

Règle 4 : pas d'alias de chemins dans tsconfig.json

Node ignore complètement compilerOptions.paths. Cet astucieux alias @/utils/logger se résout dans votre éditeur et explose à l'exécution. Utilisez des chemins relatifs, ou le champ imports propre à Node dans package.json :

{
  "imports": {
    "#store/*": "./src/store/*"
  }
}

Il s'agit d'une vraie résolution Node : elle fonctionne à l'exécution et tsc la comprend également.

Règle 5 : pas de décorateurs, pas de propriétés de paramètres

Les décorateurs restent une proposition TC39 en phase 3. Node ne les émule pas et ne le fera pas tant que JavaScript ne les prendra pas en charge nativement. Les propriétés de paramètres tombent sous la même règle :

// CASSÉ — une propriété de paramètre génère une affectation
class TaskStore {
  constructor(private db: DatabaseSync) {}
}
 
// Correct — écrivez l'affectation vous-même
class TaskStore {
  readonly #db: DatabaseSync;
 
  constructor(db: DatabaseSync) {
    this.#db = db;
  }
}

Celle-ci a de véritables conséquences : NestJS, TypeORM et l'ancien class-validator ne peuvent pas fonctionner sous le retrait natif. Ils reposent sur les métadonnées des décorateurs. Si vous en dépendez, conservez votre étape de build.

Règle 6 : pas de JSX

Le retrait des types gère les types, pas les transformations de syntaxe. Les fichiers .tsx ont toujours besoin d'un bundler. Ce tutoriel est côté serveur, donc cela ne nous gêne pas — mais c'est pourquoi Next.js et Vite ne sont pas près de disparaître.

En résumé : si votre TypeScript resterait du TypeScript valide après suppression de chaque annotation de type, Node peut l'exécuter. S'il laisserait derrière lui quelque chose qui doit être généré, il ne le peut pas.

Étape 4 : la couche de données avec node:sqlite

Node embarque un pilote SQLite dans son cœur. Depuis Node 25.7, il est marqué Release Candidate (stabilité 1.2) plutôt que pleinement stable — bon à savoir avant de miser un système critique dessus, même si son API est figée depuis plusieurs versions.

Créez src/types.ts :

export const Status = {
  Pending: "pending",
  Done: "done",
  Archived: "archived"
} as const;
 
export type Status = (typeof Status)[keyof typeof Status];
 
export interface Task {
  id: number;
  title: string;
  status: Status;
  createdAt: string;
}
 
export interface NewTask {
  title: string;
  status?: Status;
}

Puis src/store.ts :

import { DatabaseSync } from "node:sqlite";
import type { Task, NewTask, Status } from "./types.ts";
 
export function createTaskStore(path: string) {
  const db = new DatabaseSync(path);
 
  // Les tables STRICT rejettent les types incohérents au lieu de les convertir
  db.exec(`
    CREATE TABLE IF NOT EXISTS tasks (
      id        INTEGER PRIMARY KEY AUTOINCREMENT,
      title     TEXT NOT NULL,
      status    TEXT NOT NULL DEFAULT 'pending',
      createdAt TEXT NOT NULL
    ) STRICT
  `);
 
  // Les requêtes préparées sont compilées une fois et réutilisées à chaque appel
  const insertStmt = db.prepare(
    `INSERT INTO tasks (title, status, createdAt)
     VALUES (:title, :status, :createdAt)`
  );
  const listStmt = db.prepare(`SELECT * FROM tasks ORDER BY id DESC`);
  const getStmt = db.prepare(`SELECT * FROM tasks WHERE id = :id`);
  const updateStmt = db.prepare(
    `UPDATE tasks SET status = :status WHERE id = :id`
  );
  const deleteStmt = db.prepare(`DELETE FROM tasks WHERE id = :id`);
 
  return {
    list(): Task[] {
      return listStmt.all() as unknown as Task[];
    },
 
    get(id: number): Task | undefined {
      return getStmt.get({ id }) as unknown as Task | undefined;
    },
 
    create(input: NewTask): Task {
      const createdAt = new Date().toISOString();
      const result = insertStmt.run({
        title: input.title,
        status: input.status ?? "pending",
        createdAt
      });
 
      return {
        id: Number(result.lastInsertRowid),
        title: input.title,
        status: input.status ?? "pending",
        createdAt
      };
    },
 
    updateStatus(id: number, status: Status): Task | undefined {
      const result = updateStmt.run({ id, status });
      if (result.changes === 0) return undefined;
      return this.get(id);
    },
 
    remove(id: number): boolean {
      return deleteStmt.run({ id }).changes > 0;
    },
 
    close(): void {
      db.close();
    }
  };
}
 
export type TaskStore = ReturnType<typeof createTaskStore>;

Quelques points méritent d'être soulignés. node:sqlite est synchrone par conceptionDatabaseSync bloque la boucle d'événements. Pour SQLite, c'est généralement le bon choix, car une lecture sur disque local se termine plus vite que le coût d'ordonnancement d'un rappel asynchrone. Mais cela signifie qu'une requête lente bloque tout votre serveur : gardez vos requêtes indexées et évitez les parcours complets de tables.

Les paramètres nommés s'appuient sur allowBareNamedParameters, dont la valeur par défaut est true — ainsi { id } se lie à :id sans que vous écriviez les deux-points vous-même.

Les conversions as unknown as Task[] sont honnêtes vis-à-vis de la réalité : SQLite renvoie des lignes non typées, et prétendre le contraire serait pire. Dans un système plus vaste, vous valideriez ces lignes comme vous validez les requêtes entrantes.

Étape 5 : la validation sans décorateurs

Puisque class-validator et ses cousins à décorateurs sont hors jeu, écrivez la validation sous forme de fonctions ordinaires renvoyant un objet de résultat. C'est plus verbeux qu'un décorateur, mais c'est du code que vous pouvez lire et parcourir au débogueur.

Créez src/validate.ts :

import { Status } from "./types.ts";
import type { NewTask } from "./types.ts";
 
export type Validated<T> =
  | { ok: true; value: T }
  | { ok: false; errors: string[] };
 
const VALID_STATUSES = Object.values(Status) as string[];
 
export function validateNewTask(input: unknown): Validated<NewTask> {
  const errors: string[] = [];
 
  if (typeof input !== "object" || input === null) {
    return { ok: false, errors: ["body must be a JSON object"] };
  }
 
  const body = input as Record<string, unknown>;
 
  if (typeof body.title !== "string" || body.title.trim().length === 0) {
    errors.push("title is required and must be a non-empty string");
  } else if (body.title.length > 200) {
    errors.push("title must be 200 characters or fewer");
  }
 
  if (body.status !== undefined && !VALID_STATUSES.includes(String(body.status))) {
    errors.push(`status must be one of: ${VALID_STATUSES.join(", ")}`);
  }
 
  if (errors.length > 0) return { ok: false, errors };
 
  return {
    ok: true,
    value: {
      title: (body.title as string).trim(),
      status: body.status as NewTask["status"]
    }
  };
}
 
export function validateStatus(input: unknown): Validated<Status> {
  if (typeof input !== "object" || input === null) {
    return { ok: false, errors: ["body must be a JSON object"] };
  }
 
  const status = (input as Record<string, unknown>).status;
 
  if (!VALID_STATUSES.includes(String(status))) {
    return {
      ok: false,
      errors: [`status must be one of: ${VALID_STATUSES.join(", ")}`]
    };
  }
 
  return { ok: true, value: status as Status };
}

L'union discriminée Validated permet à TypeScript de restreindre le type pour vous : à l'intérieur d'une branche if (result.ok), result.value est entièrement typé et result.errors n'existe pas.

Étape 6 : la couche HTTP

Passons au serveur lui-même, en utilisant uniquement node:http. Créez src/router.ts :

import type { IncomingMessage, ServerResponse } from "node:http";
import type { TaskStore } from "./store.ts";
import { validateNewTask, validateStatus } from "./validate.ts";
 
const MAX_BODY_BYTES = 64 * 1024;
 
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
  const chunks: Buffer[] = [];
  let size = 0;
 
  for await (const chunk of req) {
    size += chunk.length;
    if (size > MAX_BODY_BYTES) {
      throw new Error("request body too large");
    }
    chunks.push(chunk as Buffer);
  }
 
  if (chunks.length === 0) return {};
 
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
 
function send(res: ServerResponse, status: number, payload: unknown): void {
  const body = JSON.stringify(payload);
  res.writeHead(status, {
    "content-type": "application/json; charset=utf-8",
    "content-length": Buffer.byteLength(body)
  });
  res.end(body);
}
 
export function createRouter(store: TaskStore) {
  return async function handle(
    req: IncomingMessage,
    res: ServerResponse
  ): Promise<void> {
    const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
    const segments = url.pathname.split("/").filter(Boolean);
    const method = req.method ?? "GET";
 
    // GET /health
    if (method === "GET" && url.pathname === "/health") {
      return send(res, 200, { status: "ok", uptime: process.uptime() });
    }
 
    if (segments[0] !== "tasks") {
      return send(res, 404, { error: "not found" });
    }
 
    // GET /tasks
    if (method === "GET" && segments.length === 1) {
      return send(res, 200, { tasks: store.list() });
    }
 
    // POST /tasks
    if (method === "POST" && segments.length === 1) {
      const body = await readJsonBody(req);
      const result = validateNewTask(body);
 
      if (!result.ok) {
        return send(res, 422, { errors: result.errors });
      }
 
      return send(res, 201, { task: store.create(result.value) });
    }
 
    // Tout ce qui suit nécessite un identifiant numérique
    const id = Number(segments[1]);
    if (segments.length !== 2 || !Number.isInteger(id) || id < 1) {
      return send(res, 400, { error: "invalid task id" });
    }
 
    // GET /tasks/:id
    if (method === "GET") {
      const task = store.get(id);
      return task
        ? send(res, 200, { task })
        : send(res, 404, { error: "task not found" });
    }
 
    // PATCH /tasks/:id
    if (method === "PATCH") {
      const result = validateStatus(await readJsonBody(req));
 
      if (!result.ok) {
        return send(res, 422, { errors: result.errors });
      }
 
      const task = store.updateStatus(id, result.value);
      return task
        ? send(res, 200, { task })
        : send(res, 404, { error: "task not found" });
    }
 
    // DELETE /tasks/:id
    if (method === "DELETE") {
      return store.remove(id)
        ? send(res, 204, {})
        : send(res, 404, { error: "task not found" });
    }
 
    return send(res, 405, { error: "method not allowed" });
  };
}

Puis le point d'entrée, src/server.ts :

import { createServer } from "node:http";
import { createTaskStore } from "./store.ts";
import { createRouter } from "./router.ts";
 
const PORT = Number(process.env.PORT ?? 3000);
const DB_PATH = process.env.DB_PATH ?? "./tasks.db";
 
const store = createTaskStore(DB_PATH);
const handle = createRouter(store);
 
const server = createServer((req, res) => {
  handle(req, res).catch((error: unknown) => {
    const message = error instanceof Error ? error.message : "unknown error";
    console.error("[request-error]", message);
 
    if (!res.headersSent) {
      res.writeHead(500, { "content-type": "application/json" });
      res.end(JSON.stringify({ error: "internal server error" }));
    }
  });
});
 
server.listen(PORT, () => {
  console.log(`[startup] listening on http://localhost:${PORT}`);
});
 
// Arrêt propre — les conteneurs envoient SIGTERM avant SIGKILL
function shutdown(signal: string): void {
  console.log(`[shutdown] received ${signal}, closing`);
  server.close(() => {
    store.close();
    process.exit(0);
  });
 
  // Sortie forcée si les connexions refusent de se vider
  setTimeout(() => process.exit(1), 10_000).unref();
}
 
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

Le .catch() sur le routeur n'est pas facultatif. Un rejet non géré dans un gestionnaire de requête asynchrone fait planter le processus dans les versions récentes de Node : chaque frontière asynchrone en a donc besoin.

Lancez-le :

npm run dev
# [startup] listening on http://localhost:3000

--watch redémarre à chaque sauvegarde et remplace nodemon. Combiné au retrait des types, votre boucle entre l'édition et le code en cours d'exécution ne comporte plus aucune étape de build.

Étape 7 : configuration et variables d'environnement

Node lit les fichiers .env nativement. Créez-en un :

# .env
PORT=3000
DB_PATH=./tasks.db

Le drapeau --env-file=.env dans vos scripts le charge — mais il lève une erreur si le fichier est absent, ce qui est le cas normal en production, où les vraies variables d'environnement sont injectées par la plateforme. Utilisez-y plutôt la variante qui ne lève pas d'erreur :

{
  "scripts": {
    "start": "node --env-file-if-exists=.env src/server.ts"
  }
}

Si vous préférez charger depuis le code, process.loadEnvFile() fait la même chose par programmation. Elle lève également une erreur sur un fichier manquant, alors protégez-la :

// Charger un fichier .env uniquement en développement ; l'ignorer s'il est absent
if (process.env.NODE_ENV !== "production") {
  try {
    process.loadEnvFile(".env");
  } catch {
    console.warn("[config] no .env file found, using process environment");
  }
}

Au-delà d'une poignée de variables, analysez et validez la configuration une fois au démarrage, afin qu'une faute de frappe échoue immédiatement plutôt qu'à 3 heures du matin :

// src/config.ts
function requireEnv(key: string): string {
  const value = process.env[key];
  if (!value) {
    console.error(`[config] missing required env var: ${key}`);
    process.exit(1);
  }
  return value;
}
 
export const config = {
  port: Number(process.env.PORT ?? 3000),
  dbPath: process.env.DB_PATH ?? "./tasks.db",
  nodeEnv: process.env.NODE_ENV ?? "development"
} as const;

Étape 8 : les tests avec node --test

Le lanceur de tests intégré gère les fichiers .ts sans configuration supplémentaire, car le retrait des types s'applique aussi aux fichiers de test.

Créez src/store.test.ts :

import { test, describe, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createTaskStore, type TaskStore } from "./store.ts";
 
describe("TaskStore", () => {
  let store: TaskStore;
 
  beforeEach(() => {
    // ":memory:" donne à chaque test une base propre, rapide et isolée
    store = createTaskStore(":memory:");
  });
 
  test("creates a task with a default status", () => {
    const task = store.create({ title: "Write the tutorial" });
 
    assert.equal(task.title, "Write the tutorial");
    assert.equal(task.status, "pending");
    assert.ok(task.id > 0);
  });
 
  test("lists tasks newest first", () => {
    store.create({ title: "first" });
    store.create({ title: "second" });
 
    const tasks = store.list();
    assert.equal(tasks.length, 2);
    assert.equal(tasks[0]?.title, "second");
  });
 
  test("updates status and returns the updated row", () => {
    const created = store.create({ title: "ship it" });
    const updated = store.updateStatus(created.id, "done");
 
    assert.equal(updated?.status, "done");
  });
 
  test("returns undefined when updating a missing task", () => {
    assert.equal(store.updateStatus(9999, "done"), undefined);
  });
 
  test("removes a task exactly once", () => {
    const created = store.create({ title: "temporary" });
 
    assert.equal(store.remove(created.id), true);
    assert.equal(store.remove(created.id), false);
  });
});

Et src/validate.test.ts :

import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { validateNewTask } from "./validate.ts";
 
describe("validateNewTask", () => {
  test("accepts a valid payload and trims the title", () => {
    const result = validateNewTask({ title: "  padded  " });
 
    assert.equal(result.ok, true);
    if (result.ok) {
      assert.equal(result.value.title, "padded");
    }
  });
 
  test("rejects an empty title", () => {
    const result = validateNewTask({ title: "   " });
    assert.equal(result.ok, false);
  });
 
  test("rejects an unknown status", () => {
    const result = validateNewTask({ title: "ok", status: "nonsense" });
 
    assert.equal(result.ok, false);
    if (!result.ok) {
      assert.match(result.errors[0] ?? "", /status must be one of/);
    }
  });
 
  test("rejects a non-object body", () => {
    assert.equal(validateNewTask("a string").ok, false);
    assert.equal(validateNewTask(null).ok, false);
  });
});

Lancez-les :

npm test

Variantes utiles :

node --test --watch 'src/**/*.test.ts'                  # relance à la sauvegarde
node --test --experimental-test-coverage 'src/**/*.test.ts'  # rapport de couverture
node --test --test-name-pattern="status" 'src/**/*.test.ts'  # filtrage par nom

Entourez les motifs glob de guillemets simples pour que votre shell les transmette à Node au lieu de les développer lui-même.

Étape 9 : la vérification de types comme barrière de CI

C'est l'étape que l'on saute, et c'est celle qui compte le plus. Node exécute votre code sans vérifier un seul type. Une faute de frappe dans un nom de propriété part directement en production, sauf si quelque chose d'autre l'intercepte.

Ce quelque chose d'autre, c'est tsc --noEmit, promu d'outil de build à barrière obligatoire :

npm run typecheck

Branchez-le en CI pour qu'il bloque les fusions :

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main]
  pull_request:
 
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: "26"
          cache: "npm"
 
      - run: npm ci
 
      # Non négociable : rien d'autre ne vérifie les types
      - name: Type check
        run: npm run typecheck
 
      - name: Test
        run: npm test

Ajoutez un hook local avant commit si votre équipe a tendance à oublier :

# .git/hooks/pre-commit
#!/bin/sh
npm run typecheck || {
  echo "Type check failed — commit aborted."
  exit 1
}

Rappelez-vous que erasableSyntaxOnly dans votre tsconfig signifie que cette même barrière intercepte aussi les violations des règles 1 et 5. Une seule commande protège à la fois la sûreté des types et la compatibilité d'exécution.

Étape 10 : déployer avec un Dockerfile à une seule étape

C'est ici que le bénéfice devient visible. Le Dockerfile TypeScript classique est multi-étapes : une étape installe tout et compile, une seconde copie dist/ dans une image allégée. Sans étape de build, toute cette chorégraphie disparaît.

FROM node:26-slim
 
WORKDIR /app
 
# Installer uniquement les dépendances de production — TypeScript n'en fait pas partie
COPY package*.json ./
RUN npm ci --omit=dev
 
# Copier les sources TypeScript ; elles SONT l'artefact déployable
COPY src ./src
 
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
 
# Exécuter avec un utilisateur non root
USER node
 
CMD ["node", "src/server.ts"]

Relisez cette ligne : npm ci --omit=dev ignore entièrement TypeScript, car la production n'a jamais besoin du compilateur. L'image contient vos fichiers sources et rien d'autre.

Construisez et exécutez :

docker build -t task-api .
docker run -p 3000:3000 task-api

Pour un .dockerignore :

node_modules
*.db
.env
.git

Si vous déployez sur une plateforme qui exécute npm start directement — Railway, Render, Fly.io — rien ne change. La commande de démarrage pointe déjà vers un fichier .ts. Il n'y a aucune commande de build à configurer.

Tester votre implémentation

Le serveur lancé, parcourez le cycle de vie complet :

# Contrôle de santé
curl -s localhost:3000/health
 
# Créer une tâche
curl -s -X POST localhost:3000/tasks \
  -H 'content-type: application/json' \
  -d '{"title":"Migrate the build pipeline"}'
# {"task":{"id":1,"title":"Migrate the build pipeline","status":"pending",...}}
 
# Lister
curl -s localhost:3000/tasks
 
# Mettre à jour le statut
curl -s -X PATCH localhost:3000/tasks/1 \
  -H 'content-type: application/json' \
  -d '{"status":"done"}'
 
# La validation rejette une entrée invalide avec un 422
curl -s -X POST localhost:3000/tasks \
  -H 'content-type: application/json' \
  -d '{"title":""}'
# {"errors":["title is required and must be a non-empty string"]}
 
# Supprimer
curl -s -i -X DELETE localhost:3000/tasks/1
# HTTP/1.1 204 No Content

Vérifiez ensuite que le filet de sécurité intercepte réellement les erreurs. Introduisez une erreur de type délibérée :

const task = store.create({ title: 42 });  // un nombre, pas une chaîne

node src/server.ts exécute cela sans se plaindre — le type est effacé et SQLite stocke la valeur. npm run typecheck échoue immédiatement. Cet écart entre les deux est précisément la raison pour laquelle l'étape 9 est obligatoire.

Dépannage

SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode — le message dit exactement ce qu'il veut dire. Convertissez l'enum au motif as const de la règle 1. Vous verrez la même classe d'erreur pour les propriétés de paramètres et les espaces de noms comportant des membres à l'exécution.

ERR_MODULE_NOT_FOUND désignant un fichier qui existe manifestement — vous avez omis l'extension .ts dans un import relatif (règle 2), ou utilisé un alias de chemin de tsconfig (règle 4).

SyntaxError: The requested module does not provide an export named 'X' — vous avez importé un type comme une valeur. Ajoutez le mot-clé type (règle 3). Activer verbatimModuleSyntax empêche que cela se reproduise.

ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING — Node refuse délibérément de retirer les types dans node_modules. Une dépendance publie du .ts brut. Signalez-le en amont ; les paquets publiés devraient livrer du JavaScript.

Erreurs d'analyse sur les décorateurs — aucun contournement n'existe sous le retrait natif. Abandonnez la bibliothèque à décorateurs, ou conservez une étape de compilation pour ce projet.

Les types passent en local mais la CI échoue — vérifiez que la CI tourne bien sur Node 26. Les versions antérieures se comportent différemment, et --experimental-transform-types n'existe plus pour masquer l'écart.

Tout est lent sous charge — souvenez-vous que node:sqlite est synchrone. Profilez vos requêtes et ajoutez des index ; un index manquant bloque la boucle d'événements à chaque requête.

Quand vous ne devriez pas faire cela

Être honnête sur les limites compte davantage que l'argumentaire commercial. Conservez votre étape de build si :

  • Vous utilisez NestJS, TypeORM ou class-validator — tous dépendants des décorateurs, tous incompatibles
  • Vous rendez du JSX/TSX — Next.js, Vite et consorts ont toujours besoin de bundlers
  • Vous avez besoin de tree-shaking ou de minification pour des fonctions serverless sensibles au démarrage à froid
  • Vous devez prendre en charge Node 22 ou antérieur sur une cible de déploiement
  • Votre base de code repose fortement sur les enums et les espaces de noms et la migration n'en vaut pas la peine

Le retrait natif est idéal pour les services backend, les CLI, les scripts, les workers et les tâches planifiées — du code dont vous maîtrisez la syntaxe et où le démarrage rapide prime sur l'optimisation du bundle.

Prochaines étapes

Prolongez ce que vous avez construit :

  1. Ajoutez une journalisation structurée — remplacez console.log par un vrai logger émettant des lignes JSON indexables par votre plateforme
  2. Ajoutez une limitation de débit — voyez notre guide sur la limitation de débit avec Upstash Redis
  3. Ajoutez de l'observabilité — instrumentez avec le traçage OpenTelemetry
  4. Migrez vers une vraie base de données — l'interface du store est assez petite pour être remplacée par Drizzle ORM
  5. Comparez les chemins de compilation — lisez notre article sur le compilateur Go de TypeScript 7, qui rend la barrière de vérification de types nettement plus rapide

Conclusion

Node.js 26 réduit la chaîne d'outils TypeScript côté backend à presque rien. Vous avez construit une API REST complète — routage, persistance, validation, tests, arrêt propre, déploiement Docker — avec exactement une dépendance de développement et zéro dépendance d'exécution. Pas de dist/, pas de configuration de bundler, et aucun écart entre le code que vous avez écrit et le code qui s'exécute.

Le compromis est explicite et mérite d'être rappelé : vous renoncez aux enums, aux décorateurs, aux propriétés de paramètres, aux alias de chemins et à JSX. En échange, vous obtenez un démarrage instantané, un déploiement trivial et des traces d'appels qui pointent sur de vraies lignes dans de vrais fichiers.

La seule discipline que cela exige, c'est que tsc --noEmit devienne non négociable. Node exécutera tout ce que vous lui donnerez. Votre pipeline de CI est désormais la seule chose qui sépare une faute de frappe de la production — mettez cette barrière en place en premier, avant d'écrire le deuxième fichier.