TypeScript, enfin
Nous avons repoussé ce moment quatre fois.
Chapitre 10 : "Le jour où nous voudrons que ces réponses soient vérifiées par la machine, nous ajouterons TypeScript."
Chapitre 12 : "Pour assurer la cohérence entre ces 2 classes, on utilisera TypeScript pour décrire une interface."
Chapitre 21 : "En TypeScript, un type marqué coûte une ligne et interdit de passer un tenantId à la place."
Le jour est venu.
Pourquoi pas au chapitre 1 ?
Soyons honnêtes : commencer en TypeScript aurait été parfaitement défendable, et c'est ce que fait la majorité des équipes aujourd'hui. Ce cours a choisi autrement pour une raison précise, qu'il faut assumer.
Un système de types est un aimant. Quand il est là dès la première ligne, on modélise dans les types. On dessine interface Booking, on hésite entre un type et une interface, on s'interroge sur la généricité de Repository<T> — et l'on n'a toujours pas parlé au client.
Or les huit premiers chapitres de ce cours ne contiennent presque pas de code. Ils contiennent des questions : est-ce une entité ou une valeur ? une commande ou une requête ? qu'est-ce qui doit rester inchangé pendant la réservation ? La réponse à ces questions ne se trouve pas dans un système de types.
Le raisonnement, en une phrase :
Les types décrivent une conception. Ils ne la produisent pas.
Maintenant que la conception existe — des ports, des Value Objects, des entités, un contexte, des erreurs codées — la typer coûte quelques heures et rapporte beaucoup.
Si votre équipe démarre en TypeScript, très bien. Gardez seulement cette vigilance : quand une modélisation résiste, le problème est presque toujours métier, pas syntaxique.
La migration se fait par la porte, pas par la fenêtre
On ne réécrit pas quarante fichiers un dimanche.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"allowJs": true,
"checkJs": false,
"noEmit": true
}
}allowJs: true autorise la cohabitation. On renomme un fichier en .ts, on corrige ce qui râle, on passe au suivant.
strict: true dès le premier jour. Activer la rigueur plus tard, c'est traiter en une fois une dette accumulée exprès — et personne ne le fait jamais.
L'ordre de migration n'est pas indifférent. Commencez par ce qui a le plus de lecteurs :
- les ports — ils sont le contrat entre le domaine et l'infrastructure
- les valeurs et les entités — elles sont partout
- les commandes
- l'infrastructure et l'UI — en dernier, ce sont des feuilles
Les ports deviennent des contrats
Voici la dette du chapitre 12, remboursée :
// core/ports.ts
export interface BookingRepository {
save(booking: Booking): Promise<void>;
findById(id: BookingId): Promise<Booking | null>;
findOverlapping(accommodationId: AccommodationId, stay: Stay): Promise<Booking[]>;
listBookingsForTenantId(tenantId: TenantId): Promise<Booking[]>;
}
export interface DateProvider {
today(timeZone?: string): CalendarDay;
}
export interface Dependencies {
users: UserRepository;
accommodations: AccommodationRepository;
bookings: BookingRepository;
sessions: SessionRepository;
queries: Queries;
notifications: Notifications;
dateProvider: DateProvider;
idProvider: IdProvider;
passwords: PasswordHasher;
unitOfWork: UnitOfWork;
logger: Logger;
}MemoryBookingRepository implements BookingRepository, SQLBookingRepository implements BookingRepository. Une méthode oubliée, une signature qui dérive : la compilation s'arrête.
Et une commande n'est plus une fonction floue :
export type UseCase = (dependencies: Dependencies, context: Context) => Promise<Context>;
export type Command<P> = (payload: P) => UseCase;
export const book: Command<BookPayload> = (payload) => async (dependencies, context) => {
// ...
};Le currying du chapitre 10, qui demandait un paragraphe d'explication, tient maintenant en une ligne de type.
Attention au contresens le plus fréquent. Ces interfaces ne remplacent pas les tests de contrat du chapitre 38. Un type garantit qu'une méthode findOverlapping existe et prend un Stay. Il ne dit rien de ce qu'elle répond quand les bornes se touchent.
Les types vérifient les formes. Les tests vérifient les comportements.
Une équipe qui supprime ses tests parce qu'elle a des types découvre la différence en production.
Un identifiant n'est pas une chaîne
Le chapitre 21 avait ouvert une parenthèse, la voici refermée :
// core/ids.ts
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
export type TenantId = Brand<string, "TenantId">;
export type AccommodationId = Brand<string, "AccommodationId">;
export type BookingId = Brand<string, "BookingId">;
export const TenantId = (value: string) => value as TenantId;Cinq lignes, écrites une fois. Et ceci ne compile plus :
book({ accommodationId: user.id, ... }); // Type 'TenantId' is not assignable to 'AccommodationId'Une inversion d'identifiants est le bug le plus bête et le plus coûteux qui soit : le code s'exécute, la requête s'exécute, aucune exception, et les données sont fausses. Rappelez-vous le chapitre 21 :
accommodationIdn'en coche aucun : c'est une chaîne opaque, sans règle, qui ne se combine avec rien. Elle reste une chaîne.
En JavaScript, en faire un Value Object aurait coûté une classe pour rien. En TypeScript, cela coûte une ligne — et le coût nul change la conclusion. C'est exactement pour cela que le choix du langage fait partie de la conception, et non l'inverse.
Notez que la marque n'existe qu'à la compilation. À l'exécution, c'est une chaîne. Rien à sérialiser, aucun surcoût.
Le Result, typé
Notre classe du chapitre 21 se type presque telle quelle, à un détail près, et ce détail fait toute la différence :
export class Result<T, E = DomainError> {
private constructor(private readonly _value: T | null,
private readonly _error: E | null) { Object.freeze(this); }
static ok<T>(value: T): Result<T, never> { return new Result(value, null); }
static error<E>(error: E): Result<never, E> { return new Result(null, error); }
isOk(): this is Result<T, never> { return this._error === null; }
isError(): this is Result<never, E> { return this._error !== null; }
get value(): T { return this._value as T; }
get error(): E { return this._error as E; }
map<U>(fn: (value: T) => U): Result<U, E> { /* ... */ }
flatMap<U>(fn: (value: T) => Result<U, E>): Result<U, E> { /* ... */ }
}isOk(): this is Result<T, never> est un prédicat de type. Sans lui :
const stay = Stay.parse(payload);
if (stay.isError()) return context.withError(stay.error);
stay.value.startsAfter(today); // sans prédicat : value est peut-être nullAvec lui, le compilateur sait qu'après le return, stay.value existe. Le code du chapitre 21 n'a pas changé d'une ligne ; il est simplement devenu vérifiable.
Une alternative, très répandue et tout à fait légitime, remplace la classe par une union :
type Result<T, E = DomainError> =
| { ok: true; value: T }
| { ok: false; error: E };Le rétrécissement de type est alors gratuit, sans prédicat. On y perd map et flatMap — qu'il faut réécrire en fonctions — et le Stay.parse sans un seul if du chapitre 21 y perd en élégance.
Les deux se défendent. Ce qui compte est d'en choisir un : deux façons de représenter un échec dans la même base de code, c'est la garantie d'oublier de traiter l'une des deux.
unknown à la frontière
Le chapitre 40 s'interrogeait sur la validation du corps des requêtes. TypeScript apporte un argument supplémentaire, et il est solide :
router.post("/api/bookings", handle((request) => [
authenticate(request.cookies.session),
book(request.body), // request.body est de type `any`
]));any désactive le compilateur. Tout ce qui touche cette valeur cesse d'être vérifié, silencieusement, en profondeur. C'est le plus sûr moyen de perdre le bénéfice de la migration.
Deux réponses possibles, toutes deux acceptables.
Déclarer le corps unknown et laisser les Value Objects faire leur travail — Occupancy.of et Stay.parse acceptent n'importe quoi et retournent un Result. C'est cohérent avec le chapitre 40, et cela évite d'écrire les règles deux fois.
Ou faire passer le corps par un schéma (Zod, Valibot) qui produit un type à partir de la validation. Vous gagnez un type précis à l'entrée ; veillez seulement à ce que ce schéma vérifie la forme (des chaînes, des nombres, des champs présents) et jamais le sens (au moins une nuitée, au moins un adulte). Le sens appartient au domaine, il y est déjà, et il y est testé.
La règle qui tranche, et qui vaut au-delà de ce cas :
anyest un aveu.unknownest une frontière.
Ce que les types ne feront pas pour vous
Terminons en refroidissant l'enthousiasme, comme au chapitre 21.
Un as annule tout. data as Booking[] sur une réponse d'API n'est pas une vérification, c'est une affirmation. Le jour où le serveur change un champ, le compilateur reste silencieux et le plantage arrive à l'affichage. Chaque as est une garantie que vous donnez au compilateur à sa place.
Les données à l'exécution ne sont pas typées. Une réponse HTTP, une ligne de base de données, un JSON.parse : ce sont des unknown déguisés. La frontière du chapitre 37 (toDomain) reste indispensable, types ou pas.
Les décorateurs ne sont pas de l'injection de dépendances. Vous verrez des exemples avec @Injectable() et reflect-metadata, imités d'autres écosystèmes. Regardez ce que nous faisons depuis le chapitre 12 : un objet littéral passé à un constructeur. C'est de l'injection de dépendances, complète, testable, sans métaprogrammation et sans conteneur à configurer.
De même, pour décrire un port, une interface suffit — pas besoin d'une classe abstraite. Une interface disparaît à la compilation ; une classe abstraite laisse du code, et vous invite à y mettre une implémentation par défaut que personne n'attendait.
Un type n'est pas un test. Redisons-le, c'est l'erreur la plus coûteuse : strict: true ne garantit ni qu'un séjour dure au moins une nuit, ni que la rotation du samedi est autorisée, ni que le repository SQL se comporte comme celui en mémoire.
Le typage attrape les erreurs de forme, à l'écriture, gratuitement, à l'infini. Les tests attrapent les erreurs de sens. Vous avez besoin des deux, et vous ne pouvez remplacer ni l'un par l'autre.
Voyons maintenant ce que les types apportent là où ils excellent : dans l'interface graphique.