TypeScript pour Node 2026 — natif, tsx, swc, tsc
TL;DR — En 2026, Node exécute du TypeScript nativement (strip-types, type-stripping ou transform-types selon le flag), donc le débat n'est plus "comment transpiler" mais "quelle stack de types pour quel besoin". Pour le dev :
node --strip-typesoutsx(watch + ESM impeccable). Pour le build de prod :tsc(référence, plus lent),swc(10× plus rapide, parfait pour CI), ou rien si tu pars en strip-types natif. Côté types, l'idiomatique 2026 esttsconfigstrict,moduleResolution: "NodeNext",verbatimModuleSyntax: true,satisfies,consttype params, branded types, et zéroany.
🧠 Mental model — ASCII + analogie
TypeScript dans Node, c'est deux questions orthogonales :
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ EXÉCUTION : qui enlève │ │ TYPES : qui vérifie │
│ ou transforme la syntaxe │ │ la correction ? │
│ TS ? │ │ │
└──────────────┬──────────────┘ └──────────────┬──────────────┘
│ │
┌─────────────┼─────────────┐ ┌──────────┴───────────┐
▼ ▼ ▼ ▼ ▼
node natif tsx/swc tsc (emit) tsc --noEmit IDE (tsserver)
(strip) (transform) legacy CI gate dev loopLe piège : ces deux axes sont indépendants. Tu peux exécuter avec node --strip-types (sans vérification) et vérifier les types en parallèle avec tsc --noEmit. C'est même le pattern recommandé en 2026 : séparer "fais marcher le code" de "valide les types".
Analogie : tsc était jadis le four et le contrôleur qualité. En 2026, on a une cuisine moderne où Node est le four rapide (strip), swc est le micro-ondes (transform ultra-rapide pour CI), et tsc --noEmit est le contrôleur qualité dédié.
🛠️ Code minimal — type-stripping natif
Depuis Node 22, le flag --experimental-strip-types permet d'exécuter directement du TypeScript. Depuis Node 23.6 (et stabilisé en 24), c'est activé par défaut pour la syntaxe TS-uniquement (pas de transformations : decorators, enums, namespaces nécessitent --experimental-transform-types).
// src/server.ts
import { createServer, IncomingMessage, ServerResponse } from 'node:http'
const port: number = Number(process.env.PORT ?? 3000)
const handler = (req: IncomingMessage, res: ServerResponse): void => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
}
createServer(handler).listen(port, () => {
console.log(`up on :${port}`)
})Exécution :
# Node 22 : flag requis
node --experimental-strip-types src/server.ts
# Node 24 : juste node
node src/server.ts
# Si tu utilises enums ou decorators (transformation requise)
node --experimental-transform-types src/server.tsImportant : le strip-types ne fait aucune vérification. C'est uniquement un enlèvement syntaxique (les annotations sont remplacées par des espaces pour préserver les line numbers). La vérification des types se fait à part :
# CI gate : vérification stricte
tsc --noEmit --project tsconfig.json🛠️ Code minimal — tsx pour le dev loop
tsx est l'option de loin la plus populaire pour le développement local : ESM-first, watch mode, source maps, support de paths du tsconfig.
# Run direct
tsx src/server.ts
# Watch + restart automatique
tsx watch src/server.ts
# Avec env file (Node 20+)
node --env-file=.env --import tsx/esm src/server.tstsx utilise esbuild sous le capot. C'est rapide, fiable, et il gère les coins sombres (top-level await en CommonJS, import.meta, etc.).
🛠️ tsconfig.json idiomatique 2026
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"moduleDetection": "force",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"isolatedDeclarations": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowJs": false,
"incremental": true,
"tsBuildInfoFile": ".tsbuildinfo",
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}Décortiquons les options non triviales :
module: "NodeNext"+moduleResolution: "NodeNext": seul mode aligné sur la résolution Node ESM réelle (extensions.jsobligatoires dans les imports même en TS, support depackage.jsonexports).verbatimModuleSyntax: true: obligeimport typepour les imports purement de types, garantit que le code émis correspond à ce qui est écrit (pas d'imports "élidés" par magie).isolatedModules: true: nécessaire pour swc/esbuild/Node-strip qui compilent fichier par fichier.isolatedDeclarations: true(TS 5.5+) : oblige les exports publics à avoir des types explicites, ce qui permet à des outils tiers de générer les.d.tssans appeler tsc (gain de temps massif).noUncheckedIndexedAccess:arr[i]retourneT | undefined, pasT. Évite des classes entières de bugs.exactOptionalPropertyTypes:{ foo?: string }n'accepte plus{ foo: undefined }. Important pour la sérialisation JSON.
🛠️ tsc vs tsx vs swc vs Node natif — feature matrix
| Aspect | tsc | tsx | swc | node --strip-types |
|---|---|---|---|---|
| Vitesse | Lent | Rapide (esbuild) | Très rapide (Rust) | Instantané |
| Type-check | Oui | Non | Non | Non |
| Decorators | Oui | Oui | Oui | --transform-types |
| Enums | Oui | Oui | Oui | --transform-types |
| Namespaces | Oui | Limité | Limité | Non |
| Source maps | Oui | Oui | Oui | Oui (Node 22+) |
| ESM/CJS dual | Oui (avec config) | ESM par défaut | Oui | ESM/CJS détecté |
| Watch mode | Oui (-w) | Oui (watch) | Via chokidar | --watch |
Output .d.ts | Oui | Non | Non | Non |
| Bundling | Non | Non | Avec spack | Non |
Recommandation 2026 :
- Dev :
tsx watchounode --watch --strip-types. - CI type-check :
tsc --noEmit(référence). - Build prod (si tu compiles) :
swcpour le JS,tsc --emitDeclarationOnlypour les.d.ts. Ou abandonne le build et déploie le.tsexécuté parnode --strip-types. - Lib publiée sur npm :
tscpour le JS +.d.ts, c'est le moins surprenant pour les consommateurs.
🎯 Patterns courants
1. satisfies — typer sans élargir
Le mot-clé satisfies te permet de vérifier qu'une valeur respecte un type sans élargir son type inféré. C'est l'amélioration ergonomique la plus utile de TS 4.9+.
type RouteHandler = (req: Request) => Response | Promise<Response>
const routes = {
'/users': (req) => new Response('users'),
'/health': (req) => new Response('ok'),
} satisfies Record<string, RouteHandler>
// routes['/users'] est typé précisément, pas comme RouteHandler générique
// → autocomplete précis sur les keys, vérification du shape
routes['/health'] // OK
routes['/foo'] // ERROR: Property '/foo' does not existSans satisfies, tu devais choisir entre : Record<string, RouteHandler> (perd la précision des keys) ou rien (perd la vérification).
2. const type parameters
TS 5.0 a ajouté le modifier const sur les génériques, qui préserve la littéralité des arguments.
function defineConfig<const T>(cfg: T): T { return cfg }
const c = defineConfig({
env: 'production',
features: ['auth', 'billing'],
})
// c.env est 'production' (litéral), pas string
// c.features est readonly ['auth', 'billing'], pas string[]Cas d'usage : configs, schemas Zod-like, builders.
3. Branded types — la sécurité au niveau nominal
TypeScript est structurel : deux types avec la même forme sont interchangeables. Parfois c'est dangereux (un UserId et un OrderId sont tous deux string).
type Brand<T, B> = T & { readonly __brand: B }
type UserId = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
const toUserId = (s: string): UserId => s as UserId
const toOrderId = (s: string): OrderId => s as OrderId
function getUser(id: UserId) { /* ... */ }
const uid = toUserId('u-1')
const oid = toOrderId('o-1')
getUser(uid) // OK
getUser(oid) // ERROR: Argument of type 'OrderId' is not assignable to 'UserId'Combine avec une smart constructor qui valide :
const toUserId = (s: string): UserId => {
if (!/^u-[a-z0-9]+$/.test(s)) throw new Error('invalid UserId')
return s as UserId
}Brand avec propriété vs
unique symbol:T & { readonly __brand: B }(ci-dessus) est lisible mais la propriété__brandexiste structurellement — un objet littéral{ __brand: 'UserId' }peut passer, et le brand fuit dans leskeyof/spreads. La version industrielle utilise undeclare const sym: unique symbol(jamais émis au runtime, invisible dansObject.keys, impossible à fabriquer côté appelant) — c'est la forme utilisée dans l'exemple end-to-end plus bas. Règle :unique symbolpour les libs publiques, la version propriété pour du code applicatif jetable.
4. Type narrowing avec discriminated unions
Le pattern le plus puissant pour modéliser les états d'erreur.
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E }
async function fetchUser(id: string): Promise<Result<User>> {
try {
const r = await fetch(`/users/${id}`)
if (!r.ok) return { ok: false, error: new Error(`HTTP ${r.status}`) }
return { ok: true, value: await r.json() as User }
} catch (e) {
return { ok: false, error: e as Error }
}
}
const r = await fetchUser('u-1')
if (r.ok) {
// r est narrowed à { ok: true; value: User }
console.log(r.value.name)
} else {
console.error(r.error.message)
}Cette approche est explicitement préférée à throw pour les erreurs prévisibles (validation, not found, etc.). Le compilateur t'oblige à gérer les deux branches.
5. Type-only imports/exports
Avec verbatimModuleSyntax: true, tu dois marquer explicitement les imports de types :
import type { User } from './types.ts'
import { createUser } from './user.ts'
export type { User }
export { createUser }C'est verbeux mais ça garantit qu'un type ne devient jamais une dépendance runtime accidentelle.
6. Project references pour les monorepos
tsc recompile potentiellement tout. Avec references, tu déclares un DAG de sous-projets, et seuls les projets impactés sont rebuild.
// packages/core/tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}// packages/api/tsconfig.json
{
"compilerOptions": { "outDir": "dist" },
"references": [{ "path": "../core" }],
"include": ["src/**/*.ts"]
}tsc --build # builds tout dans le bon ordre
tsc --build --watch # watch incrémental
tsc --build --clean # nettoie les .tsbuildinfoGain typique sur un monorepo de 20 packages : 30s → 3s sur les rebuilds incrémentaux.
🔄 Versions — Node 18 / 20 / 22 / 24
| Version | Support TS |
|---|---|
| 18 | Pas de TS natif. tsx, ts-node ou swc. |
| 20 | Pas de TS natif. --loader API stable pour custom loaders. |
| 22 | --experimental-strip-types (syntaxe seule), --experimental-transform-types (decorators, enums). |
| 23 | Strip-types stable, par défaut sans flag pour syntaxe TS-only à partir de 23.6. |
| 24 | Strip-types par défaut. Performance V8 améliorée. Source maps inline gratuits. |
Côté TypeScript lui-même, viser TS 5.6+ en 2026. Les features modernes (satisfies, const type params, using declarations pour Symbol.dispose) sont devenues idiomatiques.
⚠️ Pitfalls
- Extensions
.jsdans les imports TS : enNodeNext, tu importes./foo.jsmême si le fichier source estfoo.ts. C'est contre-intuitif mais indispensable pour que le code émis fonctionne en ESM Node. tsc --noEmitlent en CI : sur un gros codebase, lance-le avec--incrementalet cache le.tsbuildinfo. Gain typique : 60s → 5s sur les PRs.anyqui se faufile via JSON.parse :JSON.parse(s)retourneany. Wrap-le danssafeParsetypé avec Zod ou similaire, sinon la sécurité des types s'évapore au runtime.- Décorateurs legacy vs nouveaux : TS 5+ a deux modes (
experimentalDecorators: true= ancien stage-2, sinon = nouveau stage-3 ECMAScript). Beaucoup de libs (TypeORM, NestJS) ne sont compatibles qu'avec l'ancien. Vérifie avant de migrer. - Enums en mode strip-types :
enum Color { Red, Green }ne marche pas avec--strip-types(c'est une transformation, pas un strip). Solution :const Color = { Red: 0, Green: 1 } as const+type Color = typeof Color[keyof typeof Color]. import.meta.dirnameindisponible en CJS : seulement en ESM. Si tu cibles dual CJS/ESM, utilisefileURLToPath(import.meta.url)en ESM et__dirnameen CJS.pathsdu tsconfig invisible au runtime :tscn'ajoute pas de résolution custom au code émis. Soit tu utilisestsc-aliasoutsconfig-pathsau runtime, soit tu passes en package references (monorepo), soit tu évitespaths.skipLibCheck: falsequi explose : un type dans@types/...que tu n'as pas écrit a un bug. Tu paies pour rien. Toujours mettreskipLibCheck: truesauf cas spécifique.noUncheckedIndexedAccessqui spam : c'est strict mais ça révèle des bugs. Combine avecif (val !== undefined)guards ou??defaults pour adoucir.- Génériques sur-spécifiés :
function id<T extends string>(x: T): Tquandfunction id(x: string): stringaurait suffi. Les génériques coûtent en lisibilité, n'en mets que quand tu préserves une relation entre types.
🧪 Testing — vérification de types comme test
tsd et expect-type permettent de tester que tes types se comportent bien :
import { expectTypeOf } from 'expect-type'
import { fetchUser } from './user.ts'
import type { Result, User } from './user.ts'
expectTypeOf(fetchUser).parameter(0).toEqualTypeOf<string>()
// fetchUser renvoie Promise<Result<User>>, pas Promise<User | null>
expectTypeOf(fetchUser).returns.resolves.toEqualTypeOf<Result<User>>()Utile pour les libs publiques où le type est l'API. Un changement de signature qui casse un consommateur devient un test rouge, pas une surprise au runtime chez l'utilisateur. Lance ces tests dans le même job CI que tsc --noEmit ; expect-type n'émet aucun code runtime, c'est purement compile-time (les assertions échouent en faisant échouer la compilation).
Subtilité staff :
tsdetexpect-typene remplacent pastsc --noEmit. Ils testent des propositions précises sur tes types publics ;tscvérifie la cohérence globale. Tu veux les deux. Le piège classique : un type devientanypar accident (un// @ts-expect-errormal placé, unJSON.parsenon wrappé) ettscne dit rien — maisexpectTypeOf<X>().not.toBeAny()l'attrape.
🎬 Cas d'usage concrets
Scénario 1 — Banque "NeoCrédit" en strict types
NeoCrédit (la néobanque) a 18 microservices Node + 1 monolithe principal en TypeScript. Avant 2024, le tsconfig était laxiste (strict: false, noImplicitAny: false) parce que la migration depuis l'historique JS avait été précipitée. Conséquence : 8 incidents en production en 2023 liés à des undefined non gérés (un nom de bénéficiaire null qui plantait l'envoi de SEPA, un montant NaN qui débitait 0€ au lieu de 100€).
En 2024, l'équipe a pris la décision de passer en strict: true + noUncheckedIndexedAccess: true + exactOptionalPropertyTypes: true. Stratégie : un sprint par mois dédié à la conversion, en commençant par les modules les plus critiques (paiement, KYC, AML). Outil clé : tsc --strict --noEmit qui crache 4500 erreurs au départ, puis baisse semaine après semaine. L'équipe utilise aussi ts-reset pour rendre des APIs Node plus sûres (JSON.parse retourne unknown, pas any). En 2026, le codebase est 100 % strict, et le compteur d'incidents prod liés à undefined est à 0 depuis 14 mois. Les nouvelles features sont livrées plus vite parce que les bugs sont attrapés au compile-time au lieu de la prod.
Scénario 2 — E-commerce "ModeCircuit" : types catalog inférés depuis Zod
ModeCircuit (marketplace mode seconde-main) a un catalog produits complexe : 12 catégories, chacune avec des attributs propres (vêtement → taille, couleur, matière ; chaussure → pointure ; sac → dimensions). Avant, ils maintenaient deux représentations : un schéma Mongoose pour la DB et une interface TS pour le code. Drift garanti : un dev ajoute season au schéma Mongoose, oublie l'interface, et 3 mois plus tard une feature crash en TS.
Solution adoptée : un seul fichier de schémas Zod par catégorie (schemas/clothing.ts, schemas/shoes.ts), avec z.discriminatedUnion('category', [Clothing, Shoes, Bag]) au niveau racine. Les types TS sont inférés via type Product = z.infer<typeof ProductSchema>. Mongoose est remplacé par drizzle-orm qui accepte les schémas Zod natifs. Bénéfice : un seul endroit de vérité, le compilateur TS hurle quand un dev oublie de gérer une nouvelle catégorie dans un switch, et la validation runtime (Zod) fait le filet de sécurité. L'équipe a aussi adopté les branded types pour ProductId, SkuId, CategoryId (impossible de mélanger). Résultat : -65 % de bugs liés au catalog en 6 mois.
Scénario 3 — SaaS RH "PaySimple" zodTypes générés depuis Prisma
PaySimple a une base Postgres avec ~80 tables (employees, contracts, payslips, leaves, expenses, etc.). Le backend utilise Prisma, qui génère ses propres types TypeScript (Prisma.Employee, Prisma.PayslipCreateInput). Mais l'équipe veut aussi des schémas Zod pour valider les inputs des endpoints HTTP — sans dupliquer.
Solution : zod-prisma-types (codegen) lit le schema.prisma et génère un fichier zodSchemas.ts avec un schéma Zod par modèle (EmployeeSchema, PayslipSchema, LeaveCreateInputSchema). Ces schémas sont utilisés côté tRPC (input(EmployeeCreateInputSchema)) pour la validation, et côté frontend pour la validation de formulaire React Hook Form. Un seul changement dans schema.prisma propage à tout : types Prisma, schémas Zod, validation form, validation API. L'équipe a réduit le code de validation de 3000 lignes manuelles à 80 lignes de schémas explicites (le reste est généré). Tradeoff : dépendre d'un codegen externe (le bus factor du package zod-prisma-types), mitigé en vendoring le code si nécessaire.
🛠️ Exemple end-to-end
Cas d'usage : "module PaySimple payslipCalculation — calcul de fiche de paie strict avec branded types pour EmployeeId et Money, discriminated union pour les composants de salaire, typeguards exhaustifs, et tests expectTypeOf".
// src/types/branded.ts
declare const __brand: unique symbol
export type Brand<T, B extends string> = T & { readonly [__brand]: B }
export type EmployeeId = Brand<string, 'EmployeeId'>
export type PayslipId = Brand<string, 'PayslipId'>
export type Money = Brand<number, 'Money'>
export const EmployeeId = (s: string): EmployeeId => s as EmployeeId
export const Money = (n: number): Money => {
if (n < 0 || !Number.isFinite(n)) throw new Error(`invalid money: ${n}`)
return Math.round(n * 100) / 100 as Money
}
// src/payslip/components.ts
export type SalaryComponent =
| { kind: 'base'; gross: Money }
| { kind: 'overtime'; hours: number; hourlyRate: Money }
| { kind: 'bonus'; reason: string; amount: Money }
| { kind: 'deduction'; reason: string; amount: Money }
| { kind: 'reimbursement'; receiptId: string; amount: Money }
export type Payslip = {
id: PayslipId
employeeId: EmployeeId
month: `${number}-${number}`
components: ReadonlyArray<SalaryComponent>
totals: { gross: Money; netBeforeTax: Money; netAfterTax: Money }
}
// src/payslip/calculate.ts
import type { SalaryComponent, Payslip } from './components'
import { Money } from '../types/branded'
const CHARGES_SALARIALES_RATE = 0.23
const PRELEVEMENT_SOURCE_RATE = 0.075
const unreachable = (x: never): never => {
throw new Error(`unreachable case: ${JSON.stringify(x)}`)
}
const componentValue = (c: SalaryComponent): number => {
switch (c.kind) {
case 'base': return c.gross
case 'overtime': return c.hours * c.hourlyRate * 1.25
case 'bonus': return c.amount
case 'deduction': return -c.amount
case 'reimbursement': return c.amount
default: return unreachable(c)
}
}
export function calculatePayslip(input: {
employeeId: EmployeeId
month: `${number}-${number}`
components: ReadonlyArray<SalaryComponent>
}): Payslip {
const gross = Money(input.components.reduce((s, c) => s + componentValue(c), 0))
const charges = Money(gross * CHARGES_SALARIALES_RATE)
const netBeforeTax = Money(gross - charges)
const tax = Money(netBeforeTax * PRELEVEMENT_SOURCE_RATE)
const netAfterTax = Money(netBeforeTax - tax)
return {
id: crypto.randomUUID() as PayslipId,
employeeId: input.employeeId,
month: input.month,
components: input.components,
totals: { gross, netBeforeTax, netAfterTax },
}
}
// src/payslip/calculate.test.ts
import { describe, it, expect } from 'vitest'
import { expectTypeOf } from 'expect-type'
import { calculatePayslip } from './calculate'
import { EmployeeId, Money, type PayslipId } from '../types/branded'
describe('calculatePayslip', () => {
it('infers branded return types', () => {
expectTypeOf(calculatePayslip).returns.toHaveProperty('id').toEqualTypeOf<PayslipId>()
expectTypeOf(calculatePayslip).returns.toHaveProperty('totals').toExtend<{ gross: Money }>()
})
it('computes net from base + bonus', () => {
const payslip = calculatePayslip({
employeeId: EmployeeId('emp-123'),
month: '2026-05',
components: [
{ kind: 'base', gross: Money(3000) },
{ kind: 'bonus', reason: 'q1-target', amount: Money(500) },
],
})
expect(payslip.totals.gross).toBe(3500)
expect(payslip.totals.netBeforeTax).toBeCloseTo(2695, 2)
})
it('rejects negative money at construction', () => {
expect(() => Money(-100)).toThrow()
expect(() => Money(NaN)).toThrow()
})
})Ce module combine cinq techniques TypeScript seniors : (1) branded types pour EmployeeId, PayslipId, Money (impossible de passer un Money là où un number brut est attendu accidentellement, et impossible de mélanger EmployeeId avec PayslipId), (2) discriminated union sur SalaryComponent avec kind comme discriminant — le compilateur narrow automatiquement dans le switch, (3) unreachable(c: never) helper qui force le exhaustiveness checking : si un dev ajoute { kind: 'commission'; ... } sans l'gérer, TS hurle au compile-time, (4) constructor function Money(n) qui valide le runtime et arrondit aux centimes (zéro virgule flottante hasardeuse), (5) tests avec expectTypeOf qui vérifient que la lib expose les bons types publiquement. C'est ce niveau de rigueur qui a permis à NeoCrédit de passer 14 mois sans incident undefined en prod.
🔁 Quand utiliser / éviter
| Stack | Quand | Éviter quand |
|---|---|---|
node --strip-types | Microservice mince, dev rapide, déploiement simple | Decorators, enums, namespaces lourds |
tsx | Dev loop confortable | CI lourd où swc serait plus rapide |
swc | Build prod CI, perf critique | Génération de .d.ts (utiliser tsc en parallèle) |
tsc | Lib publiée, génération .d.ts, référence | Build app où la vitesse prime |
| Project references | Monorepo > 5 packages | Petit projet (overhead config) |
🛠️ Declaration merging — quand l'utiliser
TypeScript permet de fusionner plusieurs déclarations du même nom. Utile pour étendre des types tiers proprement.
// Étendre le type Request d'Express avec des propriétés ajoutées par un middleware
import 'express'
declare module 'express' {
interface Request {
user?: { id: string; roles: string[] }
requestId?: string
}
}
// Ailleurs, le typage marche tout seul
app.get('/me', (req, res) => {
const userId = req.user?.id // typé !
})C'est plus propre qu'un (req as any).user. À utiliser pour les middlewares qui injectent des propriétés.
🛠️ Type narrowing avancé
User-defined type guards
type Animal = { kind: 'dog'; bark: () => void } | { kind: 'cat'; meow: () => void }
function isDog(a: Animal): a is Animal & { kind: 'dog' } {
return a.kind === 'dog'
}
function speak(a: Animal) {
if (isDog(a)) a.bark() // narrowed
else a.meow()
}Assertion functions
function assertNonNull<T>(val: T | null | undefined, msg = 'unexpected null'): asserts val is T {
if (val == null) throw new Error(msg)
}
const x = maybeGetUser()
assertNonNull(x)
x.email // x est narrowed à User (pas User | null)never pour les exhaustive checks
type Status = 'pending' | 'success' | 'failure'
function handle(s: Status) {
switch (s) {
case 'pending': return /* ... */
case 'success': return /* ... */
case 'failure': return /* ... */
default: {
const _: never = s // ← compile error si on ajoute une variante non gérée
throw new Error(`unhandled: ${s}`)
}
}
}C'est l'un des meilleurs filets de sécurité quand le domaine évolue.
🛠️ Performance TypeScript — vraies optimisations
Pour les gros projets, tsc peut devenir lent. Profilage et optimisations :
# 1. Diagnostic
tsc --extendedDiagnostics
# Affiche :
# - Files: 1234
# - Lines: 234567
# - Types: 45678 ← si > 100k, problème
# - Memory used: 800MB
# 2. Tracer pour trouver les gros consommateurs
tsc --generateTrace tracing/
# Ouvre tracing/trace.json dans chrome://tracing
# 3. skipLibCheck dans node_modules (déjà recommandé)Pièges perf typiques :
- Génériques imbriqués profonds :
Record<K, Record<L, Record<M, V>>>. Le compilateur explore tout. Aplatis si possible. - Conditional types complexes :
T extends ... ? ... : T extends ... ? ...à 5+ niveaux. Le compilateur évalue lazily mais à un coût. any[]qui se propage : unanypeut éteindre des optimisations de check. Trace-le.- Project references manquantes : sans elles, chaque rebuild compile tout.
🛠️ Generics — bien doser
Les génériques sont puissants mais surutilisés. Règle : un générique justifie son existence s'il préserve une relation entre types.
// ❌ Sur-spécifié : pas de relation préservée
function identity<T>(x: T): T { return x }
// = identique à : function identity(x: unknown): unknown { return x }
// Si tu n'utilises pas T en sortie reliée à T en entrée, le générique ne sert à rien.
// ✅ Justifié : T relie entrée et sortie
function map<T, U>(arr: T[], fn: (t: T) => U): U[] {
return arr.map(fn)
}
// ✅ Encore mieux : avec const pour préserver les littéraux
function asTuple<const T extends readonly unknown[]>(...items: T): T {
return items
}
const t = asTuple('a', 'b', 'c') // type : ['a', 'b', 'c'] (pas string[])Generics avec contraintes utiles
// Pick une clé existante
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
const user = { id: '1', name: 'Alice' }
getProp(user, 'name') // OK, retourne string
getProp(user, 'age') // ERROR: 'age' is not assignable
// Distributif sur les unions
type ToArray<T> = T extends any ? T[] : never
type X = ToArray<string | number> // string[] | number[] (pas (string | number)[])🛠️ Discriminated unions — modélisation d'état
Quand un objet peut être dans plusieurs états mutuellement exclusifs, ne pas avoir un seul type avec des champs optionnels partout — utiliser une union discriminée.
// ❌ Mauvais : tout optionnel, narrowing impossible
type Order = {
id: string
status: 'pending' | 'paid' | 'shipped' | 'cancelled'
paidAt?: Date
shippedAt?: Date
trackingNumber?: string
cancellationReason?: string
}
// ✅ Bon : chaque état a ses champs
type Order =
| { id: string; status: 'pending' }
| { id: string; status: 'paid'; paidAt: Date }
| { id: string; status: 'shipped'; paidAt: Date; shippedAt: Date; trackingNumber: string }
| { id: string; status: 'cancelled'; cancellationReason: string; cancelledAt: Date }
function describe(o: Order): string {
switch (o.status) {
case 'pending': return `pending: ${o.id}`
case 'paid': return `paid at ${o.paidAt.toISOString()}`
case 'shipped': return `tracking ${o.trackingNumber}`
case 'cancelled': return `cancelled because ${o.cancellationReason}`
}
}Le compilateur force à gérer tous les cas et narrow chaque branche correctement.
🛠️ Using declarations — Symbol.dispose (TS 5.2+)
Pour les ressources qui doivent être libérées (DB connections, file handles, locks) :
class Resource implements Disposable {
[Symbol.dispose]() {
// cleanup
console.log('disposed')
}
}
function useResource() {
using r = new Resource()
// ... use r ...
// r.[Symbol.dispose]() appelé automatiquement à la sortie du scope
}
// Async version
class DbConnection implements AsyncDisposable {
async [Symbol.asyncDispose]() {
await this.close()
}
}
async function query() {
await using db = new DbConnection()
return db.query('SELECT *')
}
// db.close() automatiquement appelé à la sortieBeaucoup plus propre que try/finally avec close manuel. Supporté en Node natif via --experimental-transform-types ou si tu compiles avec target ES2022+.
🛠️ Type-safe environment variables
Une erreur classique : process.env.PORT est typé string | undefined, et personne ne valide.
import { z } from 'zod'
const Env = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
PORT: z.coerce.number().int().min(1).max(65535),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32),
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'),
})
// Validate ONCE au boot
export const env = Env.parse(process.env)
// ^? { NODE_ENV: 'development' | 'test' | 'production'; PORT: number; ... }Au boot, si une variable manque, le process crash avec un message clair. Plus jamais de parseInt(process.env.PORT) qui retourne NaN.
🧠 Comment un staff raisonne sur "exécuter vs compiler"
La décision build/exec n'est pas un goût, c'est un arbitrage de contraintes que tu poses dans l'ordre :
- Publies-tu un package npm ? → tu DOIS émettre des
.js+.d.tsportables.tsc(outsc --emitDeclarationOnly+ swc pour le JS). Non négociable : tes consommateurs ne vont pas exécuter ton.tsavec un flag expérimental. - Décorateurs stage-2 / enums / namespaces dans ton code ? →
--strip-typesest exclu. Soit--experimental-transform-types, soit un vrai transpileur (tsx/swc/tsc). NestJS, TypeORM,class-validatorte coincent ici. - Tu déploies une app (conteneur) que tu contrôles ? → le débat est réel.
node --strip-typesdu.tsdirect = un artefact de moins, une source map de moins, un step CI de moins. Le coût : pas de tree-shaking, pas de minification, lenode_modulescomplet en prod. - Démarrage à froid critique (serverless, Lambda) ? → strip-types ajoute un coût de parse à chaque cold start (Node retranspile le fichier). Pré-compiler avec swc et déployer du
.jsminifié réduit le cold start. Mesure-le, ne le suppose pas.
Le piège de séniorité : séparer exécution et type-check signifie que rien n'empêche du code type-invalide de tourner localement. Ton dev loop (tsx watch) ne broncherait pas sur une erreur de type ; seul tsc --noEmit (IDE + CI) l'attrape. Conséquence opérationnelle : tsc --noEmit doit être un gate bloquant en CI et tourner en pre-commit (lint-staged) sur les fichiers touchés — sinon tu découvres l'erreur de type au merge, pas à l'écriture.
Tableau de décision (le seul que tu retiens)
| Contrainte dominante | Exécution | Type-check | Émission |
|---|---|---|---|
| Lib npm | n/a | tsc --noEmit (CI) | tsc (JS + .d.ts) |
| App conteneurisée, démarrage non critique | node --strip-types du .ts | tsc --noEmit (CI) | aucune |
| App serverless, cold start critique | node dist/*.js | tsc --noEmit (CI) | swc → JS minifié |
| Monorepo > 5 packages | tsx/strip en dev | tsc -b --noEmit/-b | tsc -b (project refs) |
| Décorateurs (Nest/TypeORM) | tsx ou swc | tsc --noEmit | swc ou tsc |
Observabilité et prod — ce que personne ne te dit
- Source maps en prod : sans
sourceMap/source maps inline, ta stack trace pointe vers du JS transpilé illisible. Active--enable-source-maps(Node) ou déploie avec source maps + un service (Sentry) qui les résout. En strip-types, Node 22+ préserve les line numbers (les annotations sont remplacées par des espaces), donc les stacks pointent juste sur le.ts— c'est un avantage sous-estimé. skipLibCheck: trueest un compromis de perf, pas de sécurité : il saute la vérification des.d.tstiers. Si un@types/*est buggé, tu ne le verras pas — mais tu gagnes 30–60 % de temps de compile. Le bon réflexe :truepartout, et si un type tiers te trahit, isole-le avec un wrapper typé, ne désactive pas globalement.tscn'est pas un linter de runtime : un type validé n'est jamais une garantie runtime aux frontières (HTTP, DB,JSON.parse,process.env). Toutunknownvenant du monde extérieur passe par un validateur (Zod/Valibot/ts-reset). Le système de types couvre l'intérieur ; les schemas couvrent les bords. Confondre les deux = la classe de bug nº1 en TS prod.
🏋️ Exercices
Exercice 1 — Bootstrap exec/check séparé (implémenter)
Objectif : monter un projet où le dev tourne en tsx watch, l'exécution prod en node --strip-types, et le type-check en tsc --noEmit bloquant en CI, sur la même base de code sans étape de build.
Indice/Solution : tsconfig.json avec NodeNext + verbatimModuleSyntax + noEmit: true (le check ne sert qu'à valider). Scripts : "dev": "tsx watch src/server.ts", "start": "node src/server.ts", "typecheck": "tsc --noEmit". CI : un job qui fait npm run typecheck en --incremental avec cache du .tsbuildinfo. Vérifie que les imports relatifs portent l'extension .js (sinon node --strip-types casse à l'import).
Exercice 2 — Result type sans throw + exhaustiveness (production-grade)
Objectif : remplacer une couche de fetch qui throw par un Result<T, E> discriminé, avec un match() exhaustif vérifié par le compilateur, et propager les erreurs typées sur 3 niveaux d'appel sans jamais perdre l'information d'erreur.
Indice/Solution : type Result<T,E> = { ok: true; value: T } | { ok: false; error: E }. Modélise E comme une union discriminée d'erreurs métier ({ kind: 'not_found' } | { kind: 'timeout' } | { kind: 'http'; status: number }). Le match final fait un switch (e.kind) avec default: assertNever(e). Test : ajoute une variante d'erreur et observe les switch non exhaustifs devenir rouges au compile-time.
Exercice 3 — Env validé + branded config (production-grade)
Objectif : valider process.env au boot avec Zod, exposer un env immuable et fortement typé, et brander DatabaseUrl/JwtSecret pour qu'ils ne soient pas confondus avec des string quelconques en aval.
Indice/Solution : Env.parse(process.env) dans un module importé en premier (top-level, crash au boot si invalide). z.coerce.number() pour PORT. Pour les brands, post-traite : DatabaseUrl(parsed.DATABASE_URL) où DatabaseUrl est un constructor (s: string) => Brand<string,'DatabaseUrl'>. Bonus : Object.freeze + as const satisfies pour empêcher la mutation.
Exercice 4 — using + AsyncDisposable pour un pool (production-grade)
Objectif : écrire un wrapper de connexion DB qui implémente AsyncDisposable, garantit le release() même sur exception, et se compose proprement avec await using dans un handler HTTP.
Indice/Solution : class Conn implements AsyncDisposable { async [Symbol.asyncDispose]() { await this.pool.release(this.raw) } }. Dans le handler : await using c = await pool.acquire(). Vérifie le release sur le chemin d'exception (lance une erreur au milieu, assure-toi que release est appelé). Compile avec target: ES2022 + lib incluant esnext.disposable, ou exécute avec --experimental-transform-types.
Exercice 5 — Casser puis réparer : le any invisible (break-then-fix)
Objectif : introduire un any qui se propage silencieusement (via JSON.parse non typé), prouver que tsc --noEmit ne le détecte pas, puis fermer la faille avec ts-reset (ou Zod) + un test expectTypeOf(...).not.toBeAny().
Indice/Solution : const data = JSON.parse(body); data.foo.bar.baz compile sans broncher (any éteint tous les checks). Installe @total-typescript/ts-reset (import side-effect) → JSON.parse retourne unknown, le .foo devient une erreur. Alternative robuste : const data = Schema.parse(JSON.parse(body)). Ajoute un test expectTypeOf(JSON.parse('{}')).toEqualTypeOf<unknown>() qui devient vert après ts-reset.
Exercice 6 — Migration strict: false → strict: true à froid (break-then-fix, hard)
Objectif : sur un module legacy de ~500 lignes en strict: false, activer strict + noUncheckedIndexedAccess + exactOptionalPropertyTypes, trier les ~80 erreurs en catégories, et corriger sans introduire de any ni de ! non-null assertion abusif.
Indice/Solution : active une option à la fois (commence par strictNullChecks, le plus impactant). Catégorise : (a) accès indexé non gardé → ?? default ou guard if (x !== undefined) ; (b) foo?: T passé undefined explicite → revoir le type ou retirer l'undefined à la source ; (c) vrais bugs null. Interdis-toi as et ! — chaque usage masque exactement le bug que strict vient de révéler. Mesure : compteur d'erreurs tsc qui décroît par commit.
🎤 En entretien
Q : Node exécute du TypeScript nativement en 2026. Est-ce que tsc est mort ? Non, et confondre les deux trahit un junior. --strip-types enlève la syntaxe TS sans rien vérifier — c'est de l'exécution. tsc vérifie la correction des types et émet les .d.ts portables. Tu sépares les deux : node/tsx pour faire tourner, tsc --noEmit comme gate de type en CI, tsc pour publier une lib. Les axes exécution et vérification sont orthogonaux.
Q : Pourquoi verbatimModuleSyntax et import type ? Quel bug ça prévient ? Sans lui, le compilateur "élide" les imports qu'il croit purement de types — mais un transpileur fichier-par-fichier (swc, esbuild, Node strip) ne sait pas ce qui est un type. Résultat : soit un import de type devient une dépendance runtime accidentelle (side-effect non voulu, cycle), soit un vrai import disparaît. verbatimModuleSyntax force import type explicite : ce qui est écrit est exactement ce qui est émis. Indispensable dès qu'autre chose que tsc transpile ton code.
Q : TypeScript est structurel. Quand est-ce un problème et comment tu le contournes ? Deux types de même forme sont interchangeables, donc un UserId et un OrderId (tous deux string) se mélangent silencieusement — bug de production garanti. Solution : branded/nominal types via une marque fantôme (unique symbol non émis au runtime) + smart constructors qui valident à la frontière. Tu rends impossible au compile-time de passer un OrderId là où un UserId est attendu, sans coût runtime.
Q : tsc passe au vert. Le code est-il type-safe en production ? Non — à l'intérieur, oui ; aux frontières, jamais. Tout ce qui vient du monde extérieur (HTTP body, process.env, JSON.parse, réponse DB) est unknown/any au runtime quoi qu'en dise le type statique. La type-safety s'arrête à l'edge ; au-delà il faut un validateur runtime (Zod/Valibot) ou ts-reset pour transformer ces any en unknown et forcer la validation. Le système de types couvre la logique interne, les schemas couvrent les bords. Les mélanger est la cause nº1 des bugs TS en prod.
🔗 Liens
- TypeScript handbook : https://www.typescriptlang.org/docs/handbook/intro.html
- Node TS support : https://nodejs.org/api/typescript.html
- tsx : https://github.com/privatenumber/tsx
- swc : https://swc.rs/
- isolatedDeclarations : https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/#isolated-declarations
- Type Challenges : https://github.com/type-challenges/type-challenges
- effective-typescript : https://effectivetypescript.com/
🗓️ Récap final
TypeScript dans Node 2026 n'est plus une question d'outillage : Node sait l'exécuter, et le débat se déplace vers la qualité des types. Adopte un tsconfig strict (NodeNext, verbatimModuleSyntax, noUncheckedIndexedAccess), sépare l'exécution (strip/tsx/swc) de la vérification (tsc --noEmit en CI), et utilise les patterns modernes : satisfies, branded types, discriminated unions pour les résultats. Le but n'est pas d'avoir "0 erreur tsc" mais d'avoir un système de types qui empêche les bugs réels sans devenir un obstacle à l'écriture du code.