Aller au contenu

TypeScript avancé

Dans la plupart des produits ci-dessous, une erreur de type attrapée à la compilation, c'est de l'argent ou un rôle attrapé avant qu'il n'atteigne un utilisateur : un numéro Mobile Money jamais validé, un état de paiement lu sur la mauvaise branche, une clé de traduction qui n'existe pas en français. Le système de types n'est pas une décoration posée sur le code — c'est là que vit une partie des règles du domaine, vérifiée à chaque enregistrement plutôt qu'une seule fois dans un test.

Les extraits de cette page sont tirés de dépôts que j'exploite en production ou que je maintiens — VotArena, Jungle, Kuntriz, Akuaba — rognés mais non modifiés sur le fond, chacun avec son fichier d'origine et le problème qu'il résout. Les catégories qui n'ont qu'un seul exemple réel le disent ; rien ici n'est une démonstration réécrite pour l'occasion.

2 exemples

Génériques

Des fonctions et des types paramétrés par un autre type, pour qu'un conteneur, un wrapper ou un dépôt garde exactement la forme de ce qu'il porte au lieu de l'élargir vers `unknown`.

Instrumenter tous les use cases avec un seul wrapper générique

instrumentContainer<C> ajoute la traçabilité à tous les use cases du conteneur d'injection de dépendances en une seule passe, et renvoie exactement le type C — aucun appelant n'a besoin d'un cast pour récupérer son use case. Le garde de type a été ajouté après un incident réel : un simple booléen du conteneur a atteint `new Proxy()` et mis l'API en panne le 24 septembre 2026.

export function instrumentUseCase<T extends object>(name: string, instance: T): T {
  return new Proxy(instance, {
    get(target, prop) {
      const value = (target as Record<PropertyKey, unknown>)[prop]
      if (prop !== 'execute' || typeof value !== 'function') {
        return typeof value === 'function' ? value.bind(target) : value
      }
      // …
    },
  })
}

export function instrumentContainer<C extends Record<string, unknown>>(
  container: C,
  exclude: ReadonlySet<string>,
): C {
  const isUseCase = (value: unknown): value is object =>
    typeof value === 'object' &&
    value !== null &&
    typeof (value as { execute?: unknown }).execute === 'function'
  return Object.fromEntries(
    Object.entries(container).map(([key, value]) =>
      exclude.has(key) || !isUseCase(value)
        ? [key, value]
        : [key, instrumentUseCase(value.constructor.name, value)],
    ),
  ) as C
}

SourceVotArenasrc/lib/observability/useCaseObservability.ts

Un callback générique qui garde son propre type de retour

runInTransaction<T> exécute un travail arbitraire dans une transaction MongoDB et renvoie exactement le type que ce travail résout, si bien que les opérations de crédit/débit du portefeuille se composent dans la même transaction sans qu'aucun des deux côtés ne perde son typage.

/** Runs `work` inside a Mongo multi-document transaction. */
async runInTransaction<T>(
  work: (session: ClientSession) => Promise<T>,
): Promise<T> {
  const session = await this.connection.startSession();
  try {
    let result: T;
    await session.withTransaction(async () => {
      result = await work(session);
    });
    return result!;
  } finally {
    await session.endSession();
  }
}

SourceJunglesrc/modules/wallet/wallet.service.ts (jungle-api)

1 exemple

Types conditionnels

Un type qui bifurque selon un autre type, pour qu'une combinaison invalide — un pays sans devise atteignant un champ de paiement — soit refusée avant l'exécution.

Un type conditionnel qui verrouille un pays à sa portée de paiement

Le type de la prop `scope` dépend du type générique du pays `C` : une valeur typée pour les pays éligibles au paiement (devise, opérateurs) n'accepte que `scope="payment"`, si bien qu'un pays sans Mobile Money ne peut plus se retrouver câblé dans un calcul de montant.

export interface PhoneValue<C extends PhoneCountry = CountryConfig> {
  dialCode: string
  localNumber: string
  full: string
  country: C
}

export type InternationalPhoneValue = PhoneValue<PhoneCountry>

interface PhoneInputProps<C extends PhoneCountry> {
  value: PhoneValue<C>
  onChange: (value: PhoneValue<C>) => void
  /**
   * The conditional type LOCKS the scope to the country type carried by the
   * value: a state typed CountryConfig (currency, operators) only accepts
   * scope="payment"; a bare InternationalPhoneValue only accepts
   * scope="international".
   */
  scope?: C extends CountryConfig ? 'payment' : 'international'
  countries?: readonly C[]
}

SourceVotArenasrc/presentation/components/common/PhoneInput.tsx

1 exemple

Types mappés

Un type reconstruit champ par champ à partir d'un autre type, pour qu'une forme dérivée (une réponse d'API après JSON, un dictionnaire client allégé) reste alignée sur sa source.

Dériver la forme côté client d'une entité serveur

Les réponses d'API transforment chaque Date en string une fois passées par le JSON. Serialized<T> dérive automatiquement cette forme à partir du type serveur : un hook typé avec l'entité serveur telle quelle serait faux — c'est ce type qui garde les hooks SWR honnêtes.

/**
 * Convertit les Date en string — reflète la sérialisation JSON de l'API.
 * Utilisé pour typer les réponses API dans les hooks SWR.
 */
export type Serialized<T> = {
  [K in keyof T]: T[K] extends Date
    ? string
    : T[K] extends Date | undefined
      ? string | undefined
      : T[K] extends object
        ? Serialized<T[K]>
        : T[K]
}

SourceVotArenasrc/presentation/lib/utils/format.ts

1 exemple

Infer

Extraire un type depuis l'intérieur d'une signature de fonction ou d'un type conteneur, plutôt que de réécrire la même forme à la main une seconde fois.

Lire le type de succès d'un use case sur sa propre signature

UseCaseOutput<U> extrait la valeur de succès du type de retour de execute() avec infer, si bien qu'une route ou un hook qui consomme un use case ne redéclare pas sa forme de sortie — un champ ajouté au résultat du use case est visible par le hook à la compilation suivante, sans interface à maintenir à la main.

/** Valeur portée par le Result en cas de succès de execute(). */
export type UseCaseOutput<U> = U extends {
  execute(...args: never[]): Promise<Result<infer V, unknown>>
}
  ? V
  : never

// src/presentation/hooks/useHomeFeed.ts
type HomeFeedResponse = UseCaseOutput<GetHomeFeedUseCase>

export function useHomeFeed() {
  const { data, error, isLoading } = useSWR<HomeFeedResponse>('/api/feed', fetcher, {
    // …
  })
  return { items: data?.items ?? [], loading: isLoading, error: error?.message ?? null }
}

SourceVotArenasrc/core/application/shared/useCaseTypes.ts + src/presentation/hooks/useHomeFeed.ts

1 exemple

Types littéraux de gabarit

Un type chaîne construit à partir d'un motif, pour que seules les chaînes ayant le préfixe attendu passent la compilation, pas n'importe quelle chaîne.

Une valeur de filtre composite typée par son préfixe

Le filtre « Événement / compétition / artiste » des dossiers de paiement combine trois cibles possibles en une seule valeur de requête. ScopeValue n'accepte que les trois préfixes connus plus la chaîne vide — une chaîne quelconque ne peut plus être affectée au filtre par erreur.

/** Valeur du select « Événement / compétition / artiste » : `event:<id>` … ou ''. */
export type ScopeValue = '' | `event:${string}` | `competition:${string}` | `artist:${string}`

export function scopeValueOf(f: Pick<PaymentCaseFilters, 'eventId' | 'competitionId' | 'artistId'>): ScopeValue {
  if (f.eventId) return `event:${f.eventId}`
  if (f.competitionId) return `competition:${f.competitionId}`
  if (f.artistId) return `artist:${f.artistId}`
  return ''
}

SourceVotArenasrc/presentation/components/payments/paymentCaseFilters.ts

1 exemple

Unions discriminées

Un ensemble fermé d'états, une forme d'objet par cas, où lire un champ que le cas courant ne porte pas est une erreur de compilation, pas un `undefined` à l'exécution.

Modéliser le suivi d'un paiement Mobile Money comme une union fermée

Un paiement peut être en attente, confirmé ou refusé pour l'une de trois raisons distinctes ; seul le cas d'échec porte un message. Lire `.message` en dehors de cette branche est une erreur de compilation, si bien que l'interface de suivi n'a jamais à se prémunir contre un champ absent.

export type PollPlan =
  | { mode: 'nokash'; url: string }
  | { mode: 'status-url'; url: string }
  | { mode: 'blind' }

export type PollOutcome =
  | { kind: 'pending' }
  | { kind: 'success' }
  | { kind: 'failed'; message: string }

export function interpretPollStatus(status: string | undefined, t: PollT): PollOutcome {
  switch (status) {
    case 'SUCCESS':
    case 'CONFIRMED':
    case 'FREE':
      return { kind: 'success' }
    case 'CANCELED':
      return { kind: 'failed', message: t('canceled') }
    // …
  }
}

SourceVotArenasrc/presentation/lib/paymentPolling.ts

2 exemples

Exhaustivité

Un switch qui ne compile que si chaque cas de l'union a été traité, pour qu'ajouter un état sans le traiter casse la compilation plutôt que l'application.

Une branche par défaut qui ne compile que si chaque carte est traitée

Le fil d'accueil mélange six types de cartes. Le paramètre d'assertNever est de type never une fois tous les cas ci-dessus traités — ajouter un septième type au fil sans le cas correspondant fait échouer la compilation. Si un client obsolète envoie un jour un `kind` que l'union ne connaît plus, une exception est levée avec cette valeur nommée dans le message, au lieu de renvoyer silencieusement `undefined`.

// src/core/domain/shared/assertNever.ts
export function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Variante non prise en charge : ${describe(value)}`)
}

// src/presentation/views/public/MobileHomeFeedView.tsx
function feedItemKey(item: FeedItem): string {
  switch (item.kind) {
    case 'competition':
      return `c:${item.competition.id}`
    // …
    case 'release':
      return `m:${item.release.id}`
    default:
      return assertNever(item)
  }
}

SourceVotArenasrc/core/domain/shared/assertNever.ts + src/presentation/views/public/MobileHomeFeedView.tsx

Aucune branche par défaut — c'est le compilateur qui vérifie l'exhaustivité

Le moteur d'éligibilité de Kuntriz ne pose que les questions qu'une filière d'immigration donnée exige réellement. QuestionTopic est une union à 17 membres, et topicAnswered n'a pas de default : ajouter un sujet à l'union sans ajouter son cas ici empêche la compilation — c'est ce qui force chaque nouvelle filière à garder ses questions branchées sur le moteur.

export type QuestionTopic =
  | "household"
  | "spouse"
  | "age"
  // … 14 autres sujets
  | "ties";

function topicAnswered(ctx: ScoringContext, topic: QuestionTopic): boolean {
  const a = ctx.answers;
  const set = (v: unknown) => v !== undefined && v !== null;
  switch (topic) {
    case "household":
      return true;
    case "spouse":
      return set(a.spouseEducation) || set(a.spouseLanguageLevel);
    // … un cas par membre de l'union, aucun default
    case "ties":
      return set(a.tiesToHome);
  }
}

SourceKuntrizlib/eligibility/types.ts + lib/eligibility/engine.ts

1 exemple

Gardes de type

Une fonction qui rétrécit une valeur inconnue ou faiblement typée vers un type précis après l'avoir vérifiée à l'exécution.

Rétrécir un code de langue aux langues réellement évaluées

L'assistant lit un code de langue depuis l'état client, mais seules cinq des langues supportées par l'application portent une évaluation de niveau. isSupportedLanguage rétrécit vers ce sous-ensemble, si bien qu'une langue non évaluée ne peut pas servir à indexer la table des niveaux.

export const SUPPORTED_LANGUAGES = ["fr", "en", "de", "it", "nl"] as const;
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];

export function isSupportedLanguage(
  lang: LanguageCode,
): lang is SupportedLanguage {
  return (SUPPORTED_LANGUAGES as readonly string[]).includes(lang);
}

/** Niveaux de langue déclarés, indexés par code ISO. */
const LEVEL_FIELDS: Record<SupportedLanguage, keyof WizardState> = {
  fr: "fr",
  en: "en",
  de: "de",
  it: "it",
  nl: "nl",
};

SourceKuntrizlib/assessment/wizard-model.ts

1 exemple

Fonctions d'assertion

Une fonction qui ne renvoie pas un booléen mais fait traiter son argument comme validé par le compilateur pour le reste du bloc, ou qui lève une exception.

Rétrécir une session une seule fois, pour le reste du resolver

Les resolvers GraphQL et les Server Actions commencent tous de la même façon : vérifier la session, puis l'utiliser. Après requireAuth(session), le compilateur traite session comme un Session pour le reste de la fonction — plus de vérification nulle répétée, plus de `!`.

export function requireAuth(session: Session | null): asserts session is Session {
  if (!session?.user) {
    throw new AuthenticationError()
  }
}

export function requireRole(
  session: Session | null,
  ...roles: UserRole[]
): asserts session is Session {
  requireAuth(session)

  if (!roles.includes(session?.user?.role as UserRole)) {
    throw new ForbiddenError(
      `Rôle requis : ${roles.join(' ou ')}. Vous avez : ${session?.user?.role}`,
    )
  }
}

export function requireAdmin(session: Session | null): asserts session is Session {
  requireRole(session, 'ADMIN')
}

SourceAkuabalib/permissions.ts (akuaba-gestion)

1 exemple

Types marqués (brandés)

Un type primitif marqué au niveau des types pour qu'une valeur validée et une valeur brute du même type sous-jacent ne soient plus interchangeables aux yeux du compilateur.

Un numéro de téléphone que l'adaptateur de décaissement ne peut pas recevoir non validé

Msisdn est une simple chaîne à l'exécution, mais le compilateur n'accepte que celles produites par toMsisdn ou isMsisdn. La fonction de décaissement NOKASH elle-même renvoie désormais ce type marqué, si bien qu'un numéro brut, non normalisé, ne peut plus atteindre l'adaptateur de paiement par accident — aucun format persisté n'a changé.

declare const MsisdnBrand: unique symbol

export type Msisdn = string & { readonly [MsisdnBrand]: true }

export function isMsisdn(value: unknown): value is Msisdn {
  return typeof value === 'string' && CANONICAL_RE.test(value)
}

// src/infrastructure/payment/routing/cameroonOperator.ts
export interface CameroonMsisdn {
  msisdn: Msisdn
  operator: MobileMoneyOperator
}

export function cameroonMsisdn(phone: string | null | undefined): CameroonMsisdn | null {
  // … normalise l'entrée et détecte l'opérateur …
  const msisdn = `237${national}`
  if (!isMsisdn(msisdn)) return null
  return { msisdn, operator }
}

SourceVotArenasrc/core/domain/shared/Msisdn.ts + src/infrastructure/payment/routing/cameroonOperator.ts

1 exemple

Satisfies

Vérifier un littéral contre un type sans l'élargir, pour qu'il garde ses clés et valeurs exactes disponibles ensuite tout en étant contrôlé.

Verrouiller une table de traduction codée en dur à sa propre forme

La frontière d'erreur racine s'affiche hors du contexte de next-intl, ses deux chaînes sont donc codées en dur volontairement. satisfies vérifie l'objet contre Record<AppLocale, Record<string, string>> sans l'élargir, si bien que `COPY.fr.title` garde sa clé littérale au lieu de devenir un simple `string`.

const COPY = {
  fr: {
    title: 'L’application a rencontré un problème',
    body: 'Ce n’est pas de votre fait : l’incident nous a été signalé. Rechargez la page — la plupart du temps, tout rentre dans l’ordre.',
    reload: 'Recharger',
    home: 'Retour à l’accueil',
  },
  en: {
    title: 'Something went wrong',
    body: 'This is not your fault — the incident has been reported. Reload the page: most of the time, everything comes back.',
    reload: 'Reload',
    home: 'Back to home',
  },
} satisfies Record<AppLocale, Record<string, string>>

SourceVotArenasrc/app/global-error.tsx

1 exemple

Surcharges

Plusieurs signatures d'appel pour une seule fonction, pour que l'argument passé détermine le type renvoyé.

L'argument de portée détermine le type de retour

Un appelant qui passe "payment" reçoit une valeur typée pour un pays avec devise et opérateurs ; un appelant qui passe "international" reçoit le type allégé. C'est la seule fonction à surcharges de tout le code base, et elle existe pour garder les deux portées distinctes au niveau des types, à l'appel.

/** Valeur initiale vide pour la portée demandée. */
export function emptyPhoneValue(): PhoneValue<CountryConfig>
export function emptyPhoneValue(scope: 'payment'): PhoneValue<CountryConfig>
export function emptyPhoneValue(scope: 'international'): InternationalPhoneValue
export function emptyPhoneValue(scope: PhoneScope = 'payment'): PhoneValue<PhoneCountry> {
  const country: PhoneCountry =
    scope === 'payment'
      ? DEFAULT_COUNTRY
      : (ALL_COUNTRIES.find((c) => c.iso2 === DEFAULT_COUNTRY.iso2) ?? ALL_COUNTRIES[0])
  return {
    dialCode: country.dialCode,
    localNumber: '',
    full: country.dialCode,
    country,
  }
}

SourceVotArenasrc/presentation/components/common/PhoneInput.tsx

1 exemple

Augmentation de module

Ajouter des champs à un type déclaré ailleurs — un objet de session, un catalogue de traduction — pour que l'extension soit vérifiée partout où ce type est utilisé.

Des clés de traduction vérifiées par le compilateur, pas découvertes en production

Le catalogue français fait foi pour chaque clé de traduction de l'application. Augmenter l'interface IntlMessages de next-intl avec ce catalogue fait de tout appel t('cléInconnue') n'importe où dans l'app une erreur TypeScript, pas une chaîne vide qu'un utilisateur découvre le premier.

import type { Messages as AppMessages } from './loadMessages'

declare global {
  interface IntlMessages extends AppMessages {}
}

export {}

// loadMessages.ts
export function loadMessages(locale: AppLocale) {
  return catalogs[locale]
}

export type Messages = (typeof catalogs)['fr']

SourceVotArenasrc/i18n/global.d.ts + src/i18n/loadMessages.ts

2 exemples

Types utilitaires

Dériver un type à partir d'une valeur ou d'un autre type — le type de retour d'une fonction, les membres d'un tableau `const` — plutôt que de maintenir une déclaration en double.

Typer un traducteur sans hook React dans un module pur

La logique de suivi de paiement est volontairement indépendante du framework, pour être testée unitairement sans monter de composant ; elle a quand même besoin de la forme exacte d'un traducteur next-intl. ReturnType<typeof useTranslations<'…'>> dérive ce type du vrai hook plutôt que d'écrire à la main une interface qui pourrait diverger.

import type { useTranslations } from 'next-intl'

/** Traducteur de l'espace `hooks.paymentPolling`. */
export type PollT = ReturnType<typeof useTranslations<'hooks.paymentPolling'>>

export function interpretPollStatus(status: string | undefined, t: PollT): PollOutcome {
  // …
}

SourceVotArenasrc/presentation/lib/paymentPolling.ts

Les variables d'environnement requises comme une liste typée et combinable

Une variable de production manquante retombait auparavant sur une valeur de développement par défaut, transformant une erreur de configuration en panne silencieuse. Les deux tuples `as const` sont des listes littérales typées assemblées par spread et parcourues une seule fois : l'ensemble requis en production est une donnée, pas une chaîne de if.

const REQUIRED_ALWAYS = ['MONGODB_URI'] as const

const REQUIRED_IN_PRODUCTION = [
  'JWT_ACCESS_SECRET',
  'PAYMENT_WEBHOOK_BASE_URL',
] as const

export function validateEnv(raw: Record<string, unknown>): Record<string, unknown> {
  const isProduction = raw.NODE_ENV === 'production'
  const errors: string[] = []
  const value = (key: string): string => String(raw[key] ?? '').trim()

  const required = [
    ...REQUIRED_ALWAYS,
    ...(isProduction ? REQUIRED_IN_PRODUCTION : []),
  ]
  for (const key of required) {
    if (!value(key)) errors.push(`${key} is required`)
  }
  // …
  return raw
}

SourceJunglesrc/common/config/env.validation.ts (jungle-api)

1 exemple

Génériques abstraits

Une classe de base paramétrée par le type d'identité de ses sous-classes, pour que deux entités sans rapport ne puissent jamais être comparées entre elles par erreur.

Un type d'identité qui garde les entités sans rapport séparées

Entity<TId extends DomainId> est la base de chaque agrégat de la couche domaine. Comme TId est un paramètre de type, Entity<CompetitionId> et Entity<EventId> sont des types différents pour le compilateur — comparer une compétition à un événement par id est rejeté avant l'exécution, pas rattrapé par un test.

// DomainId.ts
export abstract class DomainId {
  protected constructor(readonly value: string) {
    if (!value || value.trim().length === 0) {
      throw new Error(`${this.constructor.name} cannot be empty`)
    }
  }

  equals(other: DomainId): boolean {
    return this.value === other.value
  }
}

// Entity.ts
export abstract class Entity<TId extends DomainId> {
  protected constructor(readonly id: TId) {}

  equals(other: Entity<TId>): boolean {
    return this.id.equals(other.id)
  }
}

SourceVotArenasrc/core/domain/shared/DomainId.ts + Entity.ts

Pratiques

Le mode strict, partout
Le mode strict de TypeScript est actif dans chaque projet montré sur cette page — VotArena, Jungle, Kuntriz, Akuaba — sans échappatoire any à l'échelle du projet.
6 087 tests automatisés
La suite de tests de VotArena — 6 087 tests automatisés — est passée entièrement au vert le 25 septembre 2026. Les types attrapent les erreurs de forme à la compilation ; cette suite attrape le reste.
Lint et formatage à chaque commit
ESLint et Prettier passent via un hook pre-commit Husky (lint-staged) sur VotArena : un fichier qui échoue à l'un des deux n'atteint jamais une branche.
Un seul utilitaire d'exhaustivité, utilisé à de vraies frontières
assertNever et le type marqué Msisdn montrés ci-dessus ne sont pas du code de démonstration écrit pour cette page — ils compilent contre le vrai fil d'accueil de VotArena, le dispatch réel des callbacks de paiement et le chemin de décaissement NOKASH.

Besoin de cette rigueur sur votre produit ?

Décrivez-moi votre produit et les contraintes dans lesquelles vous travaillez.