Formation publique · 1 journée complète

TypeScript — les bases en une journée

Les sections 1 à 3 constituent le parcours « bases » : typer proprement un petit module réel et le déboguer avec les source maps. La section 4 et les suivantes vont plus loin et forment une fiche de référence complète, mais optionnelle. Gardez-la sous la main : elle vous servira peut-être plus tôt que prévu.

Prérequis. Vous avez terminé la formation vanilla JavaScript + DevTools de six jours. Vous savez déjà utiliser les modules ES, le DOM, les événements et DevTools. On ne recommence pas à zéro.

Dépôt GitHub · TypeScript — les bases en une journée

Sections

  1. 01TypeScript sans Vite (≈ 60–70 min)
  2. 02Vite : une chaîne d’outils moderne (≈ 65–80 min)
  3. 03Contrats TypeScript & mode strict (≈ 90–110 min)
  4. 04Collections, objets & constantes (≈ 105–125 min)
  5. 05Typer le navigateur (≈ 45–55 min)
  6. x1Extra : Génériques & overloads (≈ 35–45 min)
  7. x2Extra : as const, utility types & tsconfig (≈ 45–60 min)

1 TypeScript sans Vite (60–70 min)

Le navigateur ne comprend pas TypeScript. Il ne comprend que du JavaScript.

Dans cette première section, aucun outil ne masque le travail. Nous utilisons directement le compilateur TypeScript.

1.1 Compilation vs transpilation

La compilation classique prend un langage de haut niveau et produit un langage de bien plus bas niveau (assembleur ou bytecode).

La transpilation reste au même niveau d’abstraction : elle réécrit le code dans une autre variante du même langage (TypeScript → JavaScript).

TypeScript est transpilé. Les types disparaissent. Le navigateur exécute uniquement le JavaScript obtenu.

1.2 Créer le projet minimal

Créez un dossier vide et ouvrez un terminal dedans.

npm init -y
npm install --save-dev typescript

Dans package.json, remplacez la section scripts par :

{
  "scripts": {
    "build": "tsc",
    "watch": "tsc --watch"
  }
}
  • build → compile le projet une fois.
  • watch → relance la compilation après chaque sauvegarde.

1.3 tsconfig.json minimal pour la démonstration

Créez tsconfig.json à la racine :

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "ES2015",
    "rootDir": "src",
    "outDir": "dist",
    "strict": false
  },
  "include": ["src"]
}
  • target → vise volontairement ES2015 pour que TypeScript réécrive davantage de code dans cette démonstration.
  • module → produit des modules ES2015 pour rester cohérent avec cette cible.
  • rootDir → indique où se trouvent les fichiers TypeScript.
  • outDir → indique où écrire les fichiers JavaScript générés.
  • strict → désactive explicitement les vérifications strictes pour cette première étape, sans dépendre de la version du compilateur.
  • include → limite la compilation au dossier src/.

Choix pédagogique. Dans un projet moderne, nous préférerions "target": "ES2022" et "module": "ESNext". Nous les utiliserons dès la section 2.

Ici, ES2015 est volontairement ancien. TypeScript doit réécrire davantage de syntaxes modernes : la différence entre le fichier source et le JavaScript généré devient évidente, tout comme l’utilité des source maps.

Pas encore de vérifications strictes ni de source maps. Nous les activerons quand leur utilité sera visible.

Attention aux migrations et aux anciens projets

Jusqu’à TypeScript 5.9 inclus, l’absence de strict signifiait false. TypeScript 6 (2026) a changé la valeur par défaut en true, et TypeScript 7 (2026) a conservé ce nouveau comportement.

Deux projets contenant le même tsconfig.json incomplet peuvent donc produire des diagnostics différents selon leur version locale de TypeScript. Pendant une migration ou en passant d’un codebase à un autre, vérifiez la version avec npx tsc --version et rendez votre intention explicite avec "strict": false ou "strict": true.

1.4 Premier fichier TypeScript

Créez le dossier src/, puis src/main.ts :

const app = document.querySelector("#app");
const message: string = "TypeScript est en cours d’exécution.";

if (app) {
  app.textContent = message;
}

Créez ensuite index.html à la racine :

<!doctype html>
<html lang="fr">
  <head>
    <meta charset="UTF-8" />
    <title>TypeScript – Section 1</title>
  </head>
  <body>
    <div id="app">Chargement…</div>
    <script src="./dist/main.js" defer></script>
  </body>
</html>

1.5 Compiler et observer

Dans le terminal :

npm run build

Ouvrez dist/main.js. L’annotation : string a disparu. Le navigateur ne recevra que ce fichier JavaScript.

Ouvrez ensuite index.html. Le texte « Chargement… » devient « TypeScript est en cours d’exécution. »

Pour compiler après chaque modification :

npm run watch

La compilation est automatique. Le rafraîchissement du navigateur ne l’est pas.

1.6 Déboguer sans source maps

Pour rendre le problème visible, remplacez temporairement le contenu de src/main.ts par :

async function failOnPurpose() {
  await Promise.resolve();
  throw new Error("Erreur volontaire");
}

failOnPurpose();

Compilez et ouvrez dist/main.js. Pour produire du code compatible avec ES2015, TypeScript a réécrit async / await et ajouté du code intermédiaire.

Le fichier généré est maintenant beaucoup plus long que le fichier source. Les numéros de ligne ne correspondent plus.

À observer

  1. Rechargez la page et ouvrez la console.
  2. La stack trace pointe vers dist/main.js, pas vers src/main.ts.
  3. Dans Sources, posez un breakpoint sur l’erreur.
  4. Vous devez chercher la bonne ligne dans le JavaScript généré et traverser le code ajouté par TypeScript.

Sans source map, le navigateur ne connaît pas le fichier TypeScript original.

1.7 Activer les source maps

Gardez temporairement le même exemple avec failOnPurpose(). Dans tsconfig.json, ajoutez sourceMap après outDir :

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "ES2015",
    "rootDir": "src",
    "outDir": "dist",
    "strict": false,
    "sourceMap": true
  },
  "include": ["src"]
}

Compilez à nouveau :

npm run build

TypeScript crée maintenant dist/main.js.map. Ce fichier relie chaque partie du JavaScript généré à sa ligne d’origine dans src/main.ts.

À observer

  1. Rechargez la page et ouvrez les DevTools.
  2. La stack trace pointe maintenant vers src/main.ts.
  3. Dans Sources, ouvrez src/main.ts et posez le breakpoint sur l’erreur.
  4. Vous déboguez le fichier écrit, pas le code intermédiaire généré par TypeScript.

Après la comparaison, restaurez le contenu précédent de src/main.ts.

1.8 Pourquoi changer d’outillage ?

Cette installation fonctionne. Mais le cycle devient vite répétitif : compiler, rafraîchir, retrouver le JavaScript généré, gérer les modules et préparer les fichiers de production.

Dans la section 2, nous passons à une chaîne d’outils plus complète avec Vite, mieux adaptée à un projet qui grandit.

Checkpoint

  • Vous savez transformer du TypeScript en JavaScript avec tsc.
  • Vous voyez que les types disparaissent pendant la transpilation.
  • Vous utilisez dist/ comme sortie de compilation.
  • Vous avez comparé le débogage sans source map et avec source map.

2 Vite : une chaîne d’outils moderne (65–80 min)

Vite n’est pas un framework. C’est un outil de développement et de build.

Il réduit le travail répétitif sans remplacer TypeScript.

2.1 Ce que Vite apporte

  • Un serveur de développement local.
  • Un rechargement automatique après chaque sauvegarde.
  • La gestion des modules ES et de leurs imports.
  • La transformation du TypeScript en JavaScript.
  • Des source maps pendant le développement.
  • Un dossier public/ pour les fichiers servis tels quels.
  • Un build de production optimisé dans dist/.

Vite transforme le TypeScript, mais ne vérifie pas tous les types. tsc reste responsable du type-checking.

2.2 Installer Vite

Arrêtez le mode watch avec Ctrl+C. TypeScript est déjà installé depuis la section 1, mais nous le gardons dans la commande pour vérifier que les deux outils sont présents :

npm install --save-dev typescript vite

tsc n’est pas un package séparé à installer. C’est la commande fournie par le package typescript.

Dans package.json, remplacez les scripts :

{
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "preview": "vite preview"
  }
}
  • dev → lance le serveur de développement.
  • build → vérifie les types, puis crée le build de production.
  • preview → sert localement le contenu de dist/.

2.3 Adapter tsconfig.json à Vite

Remplacez le contenu de tsconfig.json :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "types": ["vite/client"],
    "noEmit": true,
    "strict": false
  },
  "include": ["src"]
}
  • module → conserve les modules ES pour Vite.
  • moduleResolution → résout les imports comme un bundler moderne.
  • types → ajoute les types fournis par Vite.
  • noEmit → laisse Vite générer les fichiers et utilise tsc uniquement pour vérifier les types.
  • strict → maintient temporairement le compilateur en mode permissif.

Nous remplacerons explicitement "strict": false par true dans la section 3.

2.4 Confier index.html à Vite

Modifiez index.html :

<!doctype html>
<html lang="fr">
  <head>
    <meta charset="UTF-8" />
    <title>TypeScript avec Vite</title>
  </head>
  <body>
    <div id="app">Chargement…</div>
    <img id="donkey" src="/big_donkey_2.gif" alt="donkey" style="position: relative; left: 0;" />
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Âne animé utilisé dans l’exercice

L’image affichée ci-dessus, big_donkey_2.gif, se trouve à côté de cette page de formation. Téléchargez-la, puis placez votre copie dans le dossier public/ de votre projet Vite. Vite la rend disponible à l’URL /big_donkey_2.gif.

Lancez le serveur de développement :

npm run dev

Ouvrez l’URL affichée dans le terminal, normalement http://localhost:5173. Vérifiez que la page et l’image de l’âne s’affichent.

2.5 Construire dist/ et décider des source maps

En développement, Vite fournit déjà les source maps. Pour un build de production, la décision est différente.

Trois avantages :

  • Les DevTools affichent les fichiers sources originaux plutôt que le JavaScript transformé et minifié.
  • Les stack traces de production peuvent retrouver le fichier et la ligne réellement écrits.
  • Un service de suivi d’erreurs peut reconstruire précisément le contexte d’un incident.

Trois inconvénients :

  • Une source map publique peut exposer le code source, sa structure et certains commentaires.
  • Les fichiers .map augmentent le volume des artefacts à construire, stocker et déployer.
  • Chaque source map doit correspondre exactement à son build. Une mauvaise version produit des diagnostics trompeurs.

Faut-il les publier ? Pas pour ce projet public. Si une application utilise un service de suivi d’erreurs, les source maps peuvent être générées, envoyées à ce service, puis exclues du déploiement public.

Par défaut, Vite ne génère pas de source maps pour la production. Arrêtez le serveur avec Ctrl+C, puis vérifiez-le :

npm run build

Ouvrez dist/. Voyez-vous un fichier .map ou les fichiers TypeScript sources ? Non. Le build contient les fichiers nécessaires à l’exécution, pas les sources de débogage.

Vite a généré le HTML, les fichiers JavaScript optimisés et les assets nécessaires. Testez ce résultat de production :

npm run preview

Ouvrez l’URL affichée, puis arrêtez l’aperçu avec Ctrl+C.

Si vous avez besoin de source maps de production, créez vite.config.ts à la racine :

import { defineConfig } from "vite";

export default defineConfig({
  build: {
    sourcemap: true
  }
});

Relancez npm run build. Les fichiers .map apparaissent maintenant dans dist/. Pour revenir au build public recommandé dans cette formation, retirez ensuite sourcemap: true.

2.6 Créer le module d’animation

Nous voulons faire partir l’âne de la gauche et le déplacer vers la droite de l’écran.

Créez le dossier src/section2/, puis src/section2/anim.ts :

export function startDonkey() {
  // Challenge :
  // 1. Récupérer l’élément #donkey
  // 2. Le faire avancer de 8px toutes les 80 ms avec setTimeout
  // 3. Quand il dépasse window.innerWidth, le supprimer du DOM
  //    (attention : il faudra plus tard gérer l’arrêt du setTimeout)
}
Besoin d’une solution ?
export function startDonkey() {
  const donkey = document.querySelector("#donkey") as HTMLElement;
  if (!donkey) return;

  let position = 0;

  function move() {
    position += 8;
    donkey.style.left = position + "px";
    setTimeout(move, 80);
  }

  move();
}

Modifiez src/main.ts pour importer et lancer l’animation :

import { startDonkey } from "./section2/anim";

const app = document.querySelector("#app");
if (app) {
  app.textContent = "TypeScript fonctionne maintenant avec Vite.";
}

startDonkey();

2.7 Voir le mouvement et déboguer le TypeScript

Lancez le serveur :

npm run dev

Ouvrez l’URL affichée. L’âne se déplace. Sauvegardez un fichier : la page se recharge automatiquement.

Ouvrez les DevTools → Sources et repérez src/section2/anim.ts. Posez un breakpoint dans move(), puis rechargez.

Cette fois, vous déboguez le fichier TypeScript original. La source map relie le code exécuté au code que vous avez écrit.

Pour comparer directement avec la section 1, reprenez temporairement failOnPurpose(). La console pointe maintenant vers src/main.ts et sa ligne TypeScript, pas vers le JavaScript intermédiaire.

À observer

  • Le breakpoint apparaît dans anim.ts, pas dans un fichier JavaScript généré à la main.
  • Les modules fonctionnent sans ajouter leurs fichiers compilés dans index.html.
  • Le serveur recharge la page après une sauvegarde.
  • Le timer continue après la suppression de l’image : nous corrigerons ce problème avec des types et un meilleur contrat.

Checkpoint

  • Vous savez expliquer ce que Vite ajoute au projet.
  • Vous séparez la transformation par Vite du type-checking par tsc.
  • Vous importez un module TypeScript et servez un fichier depuis public/.
  • Vous déboguez le TypeScript original grâce aux source maps.
  • Vous savez créer et prévisualiser un build de production dans dist/.

3 Contrats TypeScript & mode strict (90–110 min)

Pour l’instant, "strict": false reste dans tsconfig.json. Nous allons d’abord écrire plusieurs contrats TypeScript, puis préparer des cas concrets qui montreront ce que strict détecte réellement.

3.1 Premières annotations

Commençons par des types faciles à observer : un timestamp est un number, une durée en secondes est un number et un texte prêt à afficher est une string.

Créez le dossier src/utils/, puis le fichier src/utils/time.ts :

export function now(): number {
  return Date.now();
}

export function durationSince(startedAt: number): number {
  return Math.floor((now() - startedAt) / 1000);
}

export function formatDurationSince(startedAt: number): string {
  const totalSeconds = durationSince(startedAt);
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;

  return `${hours} h ${minutes} min ${seconds} s`;
}
  • now(): number retourne le timestamp actuel, en millisecondes depuis le 1er janvier 1970.
  • startedAt: number décrit le paramètre reçu par les deux autres fonctions.
  • durationSince(...): number calcule le nombre de secondes entières écoulées depuis ce timestamp.
  • formatDurationSince(...): string prépare un texte en heures, minutes et secondes.

Dans index.html, ajoutez le texte à mettre à jour juste après l’image de l’âne :

<img id="donkey" src="/big_donkey_2.gif" alt="donkey" style="position: relative; left: 0;" />
<p id="elapsed-time">Temps écoulé : 0 h 0 min 0 s</p>

Modifiez ensuite src/section2/anim.ts. À chaque nouvelle position de l’âne, nous recalculons aussi le texte :

import { formatDurationSince, now } from "../utils/time";

export function startDonkey() {
  // Le type entre <...> dans querySelector sera expliqué dans la section 4.
  const donkey = document.querySelector<HTMLElement>("#donkey");
  const elapsedTime = document.querySelector<HTMLElement>("#elapsed-time");

  if (!donkey || !elapsedTime) return;

  const donkeyElement: HTMLElement = donkey;
  const elapsedTimeElement: HTMLElement = elapsedTime;
  const startedAt: number = now();
  let position = 0;

  function move() {
    position += 8;
    donkeyElement.style.left = position + "px";
    elapsedTimeElement.textContent = `Temps écoulé : ${formatDurationSince(startedAt)}`;
    setTimeout(move, 80);
  }

  move();
}

startedAt ne change pas. Le mouvement le compare à l’heure actuelle toutes les 80 ms ; le texte change visiblement chaque seconde.

3.2 Première interface

Créez un contrat simple pour paramétrer l’animation :

interface DonkeyOptions {
  step: number;
  interval: number;
}

export function startDonkey(options: DonkeyOptions) {
  // utiliser options.step et options.interval
}

L’interface décrit la forme attendue. Le compilateur vérifie maintenant chaque appel.

3.3 Ajouter des options par défaut

Définissez les valeurs habituelles une seule fois, puis utilisez-les comme valeur par défaut du paramètre :

const defaultOptions: DonkeyOptions = {
  step: 8,
  interval: 80
};

export function startDonkey(
  options: DonkeyOptions = defaultOptions
) {
  // utiliser options.step et options.interval
}

Le type DonkeyOptions vérifie que l’objet par défaut est complet. Grâce à = defaultOptions, le paramètre peut être omis à l’appel, mais il reste toujours typé comme DonkeyOptions dans la fonction.

startDonkey();
// utilise step: 8 et interval: 80

startDonkey({ step: 12, interval: 40 });
// remplace les deux valeurs pour cet appel

Une valeur par défaut n’autorise pas un objet incomplet : startDonkey({ step: 12 }) reste refusé, car interval est obligatoire dans l’interface.

3.4 Barrel file

Créez src/section2/index.ts :

export { startDonkey } from "./anim";

Deux façons d’importer :

// Import nommé
import { startDonkey } from "./section2";

// Namespace
import * as Section2 from "./section2";
Section2.startDonkey({ step: 8, interval: 80 });

Le barrel fournit un point d’entrée au dossier et évite de multiplier les chemins d’import quand il grandit.

3.5 Unions et types littéraux

type ItemId = string | number;
type ItemState = "todo" | "doing" | "done";

function formatId(id: ItemId): string {
  return typeof id === "number" ? `#${id}` : id.toUpperCase();
}

Une union accepte plusieurs types précis. Une union de littéraux limite les valeurs autorisées.

3.6 Narrowing

Avant d’utiliser une union, réduisez-la vers un cas précis.

function getMessage(
  value: string | { message: string } | { code: number }
) {
  if (typeof value === "string") {
    return value;
  }

  if ("message" in value) {
    return value.message;
  }

  return `Erreur ${value.code}`;
}

const textMessage = getMessage("Animation terminée");
const objectMessage = getMessage({ message: "Image introuvable" });
const codeMessage = getMessage({ code: 404 });

À observer dans l’éditeur

  1. Survolez getMessage : TypeScript affiche un type de retour string, même si nous ne l’avons pas écrit.
  2. Survolez textMessage, objectMessage et codeMessage : les trois variables sont inférées comme string.
  3. Essayez getMessage(true) : l’appel est refusé, car boolean ne fait pas partie du type du paramètre.

Le paramètre indique les valeurs acceptées à l’appel. Le type de retour est déduit des return de la fonction, puis transmis automatiquement à la variable qui reçoit le résultat.

typeof réduit une primitive. in vérifie qu’une propriété existe. Pour plusieurs formes d’objet, nous pouvons faire encore plus clair : leur donner une propriété commune dont la valeur indique précisément la forme utilisée.

type LoadState =
  | { kind: "loading" }
  | { kind: "ready"; count: number }
  | { kind: "error"; message: string };

function describeState(state: LoadState) {
  switch (state.kind) {
    case "loading":
      return "Chargement…";

    case "ready":
      return `${state.count} élément(s) chargé(s)`;

    case "error":
      return `Erreur : ${state.message}`;
  }
}

const statusMessage = describeState({ kind: "ready", count: 3 });
// string

Ici, kind est le discriminant. Dans le cas "ready", TypeScript sait que count existe. Dans le cas "error", il autorise message. Essayez d’utiliser state.message dans le cas "loading" : l’éditeur le refuse.

3.7 Reprendre l’animation en TypeScript permissif

Repartons de l’animation de la section 2 au lieu d’inventer une longue liste de cas artificiels. Nous gardons le même âne, le même timer et presque le même code.

Avant de commencer, vérifiez tsconfig.json. Pour cette première comparaison, le mode permissif doit être demandé explicitement avec "strict": false :

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "types": ["vite/client"],
    "noEmit": true,
    "strict": false
  },
  "include": ["src"]
}

Attention à la version. Jusqu’à TypeScript 5.9, strict valait false par défaut. TypeScript 6 a changé cette valeur par défaut en true, et TypeScript 7 a conservé ce comportement. Ne supprimez donc pas simplement la propriété : avec notre version, son absence active le mode strict et produit immédiatement les erreurs que nous voulons garder pour l’étape suivante.

Voici le fichier index.html utilisé par cette version :

<!doctype html>
<html lang="fr">
  <head>
    <meta charset="UTF-8" />
    <title>TypeScript – Section 3</title>
  </head>
  <body>
    <div id="app">Chargement…</div>
    <img id="donkey" src="/big_donkey_2.gif" alt="donkey" style="position: relative; left: 0;" />
    <p id="elapsed-time">Temps écoulé : 0 h 0 min 0 s</p>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Créez le dossier src/section3/, puis le fichier src/section3/strict-cases.ts :

import { formatDurationSince, now } from "../utils/time";

export function startStrictDonkey(options) {
  const donkey = document.querySelector<HTMLElement>("#donkey");
  const elapsedTime = document.querySelector<HTMLElement>("#elapsed-time");
  const startedAt = now();
  let position = 0;

  function move() {
    position += options.step;
    donkey.style.left = position + "px";
    elapsedTime.textContent =
      `Temps écoulé : ${formatDurationSince(startedAt)}`;
    setTimeout(move, options.interval);
  }

  move();
}

Pendant cette première observation, gardez encore le src/main.ts de la section 2 :

import { startDonkey } from "./section2/anim";

const app = document.querySelector("#app");
if (app) {
  app.textContent = "TypeScript fonctionne avec Vite.";
}

startDonkey();

Avant de lancer le build

  • tsconfig.json contient bien "strict": false — l’absence de la propriété ne suffit plus avec TypeScript 7.
  • src/main.ts n’importe rien depuis section3/ ou strict-cases.ts.
  • strict-cases.ts contient bien la courte animation affichée ci-dessus, pas l’ancien catalogue de cas.

Pour l’instant, include: ["src"] suffit pour que tsc vérifie ce fichier. Vite ne l’exécutera cependant pas avant que nous l’importions.

Lancez le build :

npm run build

L’éditeur paraît rassurant… à tort

Le build passe. L’éditeur accepte options.step et propose l’autocomplétion pour style.left et textContent, mais TypeScript permissif laisse trois problèmes derrière lui :

  • options n’a aucun contrat et devient any.
  • #donkey peut être absent du HTML.
  • #elapsed-time peut aussi être absent.

L’absence de soulignement rouge ne prouve donc pas que le code est sûr.

3.8 Activer le mode strict, observer et corriger

Dans tsconfig.json, modifiez seulement cette propriété :

"strict": true

strict est une option « parapluie » : elle active plusieurs vérifications, dont strictNullChecks. À ce stade, vous pouvez choisir votre politique concernant null.

Deux politiques possibles pour null

  • Conserver strictNullChecks — recommandé pour un nouveau projet. Le type HTMLElement | null décrit correctement le DOM : un sélecteur peut ne rien trouver. En contrepartie, il faut vérifier le résultat ou garantir explicitement sa présence.
  • Désactiver strictNullChecks — parfois choisi pendant une migration. Le code est moins bruyant et demande moins de corrections immédiates, mais le compilateur ne protège plus le projet contre les accès à null.

Il n’existe pas d’option allowNulls. Pour conserver le reste du mode strict tout en autorisant implicitement les valeurs nulles, ajoutez cette exception juste après strict :

"strict": true,
"strictNullChecks": false

Avec cette seconde politique, les erreurs du DOM disparaissent, mais donkey.style peut toujours provoquer une erreur à l’exécution si l’image manque. Pour observer puis corriger ces erreurs dans l’exercice, gardez ici strictNullChecks activé en ne définissant pas cette exception.

Relancez le build :

npm run build

Cette fois, tsc --noEmit arrête le build avant que Vite ne transpile. Les trois faiblesses deviennent visibles :

  • options possède implicitement le type any.
  • donkey possède le type HTMLElement | null : son accès sans vérification est refusé.
  • elapsedTime possède aussi le type HTMLElement | null : son accès sans vérification est refusé.

strict n’empêche pas une valeur null d’exister à l’exécution. Il empêche de l’utiliser sans l’avoir vérifiée. L’éditeur n’a donc pas changé le JavaScript : il applique le contrat plus exigeant demandé par la configuration.

Modifier uniquement les lignes concernées

Dans src/section3/strict-cases.ts, reprenez d’abord le contrat et les valeurs par défaut des sections 3.2 et 3.3 :

interface DonkeyOptions {
  step: number;
  interval: number;
}

const defaultOptions: DonkeyOptions = {
  step: 8,
  interval: 80
};

Remplacez ensuite uniquement la signature de la fonction :

export function startStrictDonkey(
  options: DonkeyOptions = defaultOptions
) {

Juste après les deux querySelector, ajoutez la vérification choisie, puis conservez les valeurs vérifiées sous des noms non nullables :

if (!donkey || !elapsedTime) return;

const donkeyElement: HTMLElement = donkey;
const elapsedTimeElement: HTMLElement = elapsedTime;

Dans move(), remplacez uniquement les deux accès au DOM par ces valeurs vérifiées :

donkeyElement.style.left = position + "px";
elapsedTimeElement.textContent =
  `Temps écoulé : ${formatDurationSince(startedAt)}`;

Enfin, juste avant le setTimeout, arrêtez l’animation lorsque l’âne sort de l’écran :

if (position > window.innerWidth) {
  donkeyElement.remove();
  return;
}

setTimeout(move, options.interval);

Relancez npm run build. La vérification des types passe, puis Vite peut construire dist/.

Relier maintenant le fichier au projet

Créez src/section3/index.ts :

export { startStrictDonkey } from "./strict-cases";

Dans src/main.ts, remplacez seulement l’import et l’appel de l’ancienne animation :

import { startStrictDonkey } from "./section3";

// ... le reste de main.ts ne change pas

startStrictDonkey();

Le chemin exécuté devient index.html → main.ts → section3/index.ts → strict-cases.ts → utils/time.ts. Nous avons amélioré l’animation existante : options typées, éléments DOM vérifiés et timer arrêté quand l’âne sort de l’écran.

À essayer

Ajoutez les annotations, l’interface DonkeyOptions, ses options par défaut et le barrel du dossier section2/.

Complétez les unions et le narrowing. Comparez ensuite le même code avec "strict": false puis true, et corrigez les diagnostics sans désactiver les contrôles qui vous protègent.

Checkpoint

Vous savez annoter une fonction, écrire une interface, fournir des options par défaut, organiser un module avec un barrel, réduire une union, comparer TypeScript permissif au mode strict et traiter les diagnostics obtenus.

4 Collections, objets & constantes (105–125 min)

« Avancé » ne veut pas dire « compliqué pour le plaisir ». Cette section assemble des outils que vous rencontrerez constamment : tableaux typés, méthodes de collection, interfaces enrichies, dictionnaires, enums, constantes et quelques syntaxes modernes.

4.1 Le type entre <...> dans querySelector

Nous avons différé cette syntaxe dans la section 3. Le type placé entre chevrons est un argument de type : il précise à TypeScript le genre d’élément recherché.

const form = document.querySelector<HTMLFormElement>("#task-form");
const rows = document.querySelectorAll<HTMLLIElement>(".task");

if (!form) {
  throw new Error("Formulaire introuvable");
}

form.reset();
rows.forEach((row) => row.classList.add("ready"));

L’éditeur connaît maintenant form.reset() et les propriétés propres à HTMLFormElement. Le type ne cherche pas l’élément et ne le crée pas : querySelector peut toujours retourner null.

4.2 Tableaux typés

Un tableau possède un type d’élément. Les deux syntaxes suivantes sont équivalentes : CourseTask[] et Array<CourseTask>.

type TaskState = "todo" | "doing" | "done";

interface NamedItem {
  title: string;
}

interface CourseTask extends NamedItem {
  readonly id: number;
  state: TaskState;
  duration?: number;
}

const tasks: CourseTask[] = [
  { id: 1, title: "Installer Vite", state: "done", duration: 12 },
  { id: 2, title: "Animer l’âne", state: "doing" },
  { id: 3, title: "Activer strict", state: "todo" }
];

const archivedTasks: Array<CourseTask> = [];

extends NamedItem réutilise la propriété title dans un contrat plus précis. readonly id interdit de remplacer l’identifiant après la création de la tâche, tandis que duration? reste facultative.

Le ? de duration?: number rend cette propriété optionnelle. Une tâche peut donc l’omettre :

tasks.push({
  id: 4,
  title: "Découvrir les propriétés optionnelles",
  state: "todo"
}); // accepté : duration est optionnelle

En revanche, title et state restent obligatoires : tasks.push({ id: 5 }) est refusé. Lorsque vous lisez task.duration, son type est number | undefined : il faut donc prévoir le cas où la valeur n’a pas été fournie.

function getDuration(task: CourseTask): number {
  if (task.duration === undefined) return 0;
  return task.duration;
}

4.3 some, every, find, filter et map

some n’est pas un mot-clé TypeScript. C’est une méthode JavaScript. TypeScript connaît cependant le type de l’élément reçu par la fonction et le type du résultat.

const hasActiveTask = tasks.some((task) => task.state === "doing");
// boolean

const allTasksDone = tasks.every((task) => task.state === "done");
// boolean

const activeTask = tasks.find((task) => task.state === "doing");
// CourseTask | undefined

const finishedTasks = tasks.filter((task) => task.state === "done");
// CourseTask[]

const taskTitles = tasks.map((task) => task.title);
// string[]
  • some demande : « au moins un élément correspond-il ? » et s’arrête dès que la réponse est oui.
  • every demande : « tous les éléments correspondent-ils ? »
  • find retourne le premier élément trouvé, ou undefined.
  • filter retourne un nouveau tableau.
  • map transforme chaque élément et peut produire un autre type de tableau.

À observer dans l’éditeur

Survolez task dans chaque callback, puis chaque variable résultat. Aucun de ces types n’a été répété : TypeScript les propage depuis CourseTask[] et depuis le contrat de la méthode.

4.4 Forme d’objet, object ou Record ?

Une interface ou un type d’objet convient quand les propriétés sont connues et ont des rôles différents :

interface DonkeyOptions {
  step: number;
  interval: number;
  removeWhenOutside: boolean;
}

const options: DonkeyOptions = {
  step: 8,
  interval: 80,
  removeWhenOutside: true
};

Record<K, V> convient à un dictionnaire : les clés suivent un même type et toutes les valeurs suivent un même type.

type TimeUnit = "seconds" | "minutes" | "hours";

const elapsedByUnit: Record<TimeUnit, number> = {
  seconds: 42,
  minutes: 0,
  hours: 0
};

const labelsByLanguage: Record<string, string> = {
  fr: "Temps écoulé",
  en: "Elapsed time"
};

Une index signature exprime la même idée lorsque les clés ne sont pas connues à l’avance :

interface LabelsByLanguage {
  [language: string]: string;
}

const compactLabels: LabelsByLanguage = {
  fr: "Temps",
  en: "Time",
  nl: "Tijd"
};
  • DonkeyOptions décrit une structure fixe : chaque propriété a sa propre signification.
  • Record<TimeUnit, number> exige exactement les unités prévues, avec une valeur numérique pour chacune.
  • Record<string, string> accepte des clés qui ne sont pas connues à l’avance.
  • LabelsByLanguage avec son index signature est une autre écriture pour ce dernier dictionnaire.
  • Le type object signifie seulement « valeur non primitive ». Il est trop vague pour lire step, seconds ou une autre propriété utile.

4.5 Enums ou unions ?

Vous souvenez-vous de LoadState dans la section 3.6 ?

Nous avions utilisé loading, ready et error comme discriminants. La version la plus directe emploie une union de littéraux de chaînes :

type LoadState =
  | { kind: "loading" }
  | { kind: "ready"; count: number }
  | { kind: "error"; message: string };

const readyState: LoadState = { kind: "ready", count: 3 };
const statusMessage = describeState(readyState);
// "3 élément(s) chargé(s)"

Comparons cette union avec deux formes d’enum. Les listes et objets as const, plus techniques, sont déplacés dans l’Extra 2.

Enum numérique

enum AnimationPhase {
  Waiting,
  Moving,
  Finished
}

function phaseMessage(phase: AnimationPhase): string {
  if (phase === AnimationPhase.Moving) return "L’âne avance";
  if (phase === AnimationPhase.Finished) return "Animation terminée";
  return "Animation en attente";
}

const animationMessage = phaseMessage(AnimationPhase.Moving);
// "L’âne avance" ; la valeur exécutée de Moving est 1

Sans valeur explicite, un enum numérique commence à 0 et incrémente automatiquement. Il crée aussi un objet JavaScript utilisable pendant l’exécution.

La même idée avec un enum de chaînes

enum LoadKindEnum {
  Loading = "loading",
  Ready = "ready",
  Error = "error"
}

const enumKind = LoadKindEnum.Ready;
// LoadKindEnum.Ready, dont la valeur exécutée est "ready"

type LoadStateWithEnum =
  | { kind: LoadKindEnum.Loading }
  | { kind: LoadKindEnum.Ready; count: number }
  | { kind: LoadKindEnum.Error; message: string };

const enumReadyState: LoadStateWithEnum = {
  kind: LoadKindEnum.Ready,
  count: 3
};

Le switch de la section 3.6 garde la même mission. Avec l’enum, ses cas deviennent par exemple case LoadKindEnum.Ready. Avec l’union, les chaînes "loading", "ready" et "error" restent directement utilisables.

Une donnée reçue d’une API reste d’abord une simple string : le type TypeScript ne la valide pas à l’exécution. Un enum de chaînes attend quant à lui l’un de ses membres, comme LoadKindEnum.Ready.

  • Enum : un nom commun et un objet généré à l’exécution. Exemple : phaseMessage(AnimationPhase.Moving).
  • Union simple : seulement un contrat de type, sans objet JavaScript produit. Exemple : kind: "ready" dans LoadState.

Pour des valeurs échangées avec une API ou stockées durablement, préférez des chaînes ou des nombres explicitement fixés. Réordonner un enum numérique automatique peut changer ses valeurs sans que le nom ait changé. L’Extra 2 présentera ensuite les alternatives plus techniques avec as const.

4.6 Accès optionnel, valeur de secours et spread

const activeTitle =
  tasks.find((task) => task.state === "doing")?.title
  ?? "Aucune tâche active";

function completeTask(task: CourseTask): CourseTask {
  return { ...task, state: "done" };
}
  • ?. continue seulement si la valeur existe.
  • ?? fournit une valeur de secours pour null ou undefined.
  • ...task copie les propriétés avant de remplacer state.

Mini exercice

Construisez un petit tableau de bord des étapes de l’animation. Utilisez une union de chaînes pour les états, une interface de base étendue par CourseTask, un identifiant readonly et une durée optionnelle.

Ajoutez un Record<TaskState, string> pour les libellés. Exploitez ensuite some, every, find, filter et map. Affichez un résultat dans un élément récupéré avec querySelector<HTMLUListElement>.

Terminez avec ?., ?? et une fonction qui retourne une copie de tâche avec le spread. Les génériques et overloads sont volontairement laissés pour l’Extra 1.

Voir un exemple de solution
type TaskState = "todo" | "doing" | "done";
const taskStates: TaskState[] = ["todo", "doing", "done"];

interface NamedItem {
  title: string;
}

interface CourseTask extends NamedItem {
  readonly id: number;
  state: TaskState;
  duration?: number;
}

const tasks: CourseTask[] = [
  { id: 1, title: "Installer Vite", state: "done", duration: 12 },
  { id: 2, title: "Animer l’âne", state: "doing" },
  { id: 3, title: "Activer strict", state: "todo" }
];

const stateLabels: Record<TaskState, string> = {
  todo: "À faire",
  doing: "En cours",
  done: "Terminé"
};

const hasActiveTask = tasks.some((task) => task.state === "doing");
const allTasksDone = tasks.every((task) => task.state === "done");
const activeTask = tasks.find((task) => task.state === "doing");
const finishedTasks = tasks.filter((task) => task.state === "done");
const finishedTitles = finishedTasks.map((task) => task.title);

const activeTitle = activeTask?.title ?? "Aucune tâche active";
const activeDuration = activeTask?.duration ?? 0;

function completeTask(task: CourseTask): CourseTask {
  return { ...task, state: "done" };
}

const stateOptions = taskStates.map((state) => ({
  value: state,
  label: stateLabels[state]
}));

const output = document.querySelector<HTMLUListElement>("#task-output");
if (output) {
  output.innerHTML = finishedTitles
    .map((title) => `<li>${title}</li>`)
    .join("");
}

console.log({
  hasActiveTask,
  allTasksDone,
  activeTitle,
  activeDuration,
  stateOptions,
  completed: activeTask ? completeTask(activeTask) : undefined
});

Checkpoint

Vous savez typer un tableau, utiliser les principales méthodes de collection, étendre une interface, distinguer une forme d’objet d’un dictionnaire Record, manipuler les propriétés optionnelles et comparer enums et unions.

5 Typer le navigateur (45–55 min)

5.1 Typer les événements

button.addEventListener("click", (event: MouseEvent) => {
  console.log(event.clientX, event.clientY);
});

window.addEventListener("keydown", (event: KeyboardEvent) => {
  console.log(event.key);
});

form.addEventListener("submit", (event: SubmitEvent) => {
  event.preventDefault();
});

Dans le DOM natif, un envoi de formulaire utilise SubmitEvent. Le type plus général Event convient aussi lorsque les propriétés spécifiques au formulaire ne sont pas nécessaires.

5.2 Modules ES typés

// course-task.ts
export interface CourseTask {
  readonly id: number;
  title: string;
  state: "todo" | "doing" | "done";
}

// task-stats.ts
import type { CourseTask } from "./course-task.js";

export function countFinished(tasks: CourseTask[]): number {
  return tasks.filter((task) => task.state === "done").length;
}

Les imports et exports transportent les contrats entre les fichiers. import type disparaît à la compilation : le navigateur ne reçoit que le JavaScript nécessaire au rendu.

5.3 Typer les pages Instagram favorites de Jem

L’API de la cinquième journée JavaScript est maintenant disponible dans le même dossier api/ que cette formation. Copiez-collez son URL complète :

https://training.dercetech.com/trainings/typescript-basics/api/random-insta-page-I-like.php

Ouvrez cette URL plusieurs fois et observez le JSON avant d’écrire le moindre type. Chaque requête choisit une page Instagram favorite différente. Repérez les propriétés présentes, le type de url et les valeurs possibles de category.

Mini exercice — typer les pages Instagram favorites de Jem

À partir des réponses observées, créez les types, le module de requête, le rendu d’un lien et un bouton « Une autre page ». Le clic doit refaire la requête et remplacer le lien affiché.

Voir une proposition de solution
<button id="another-page" type="button">Une autre page</button>
<p id="instagram-result"></p>
// instagram-page.ts
export type InstagramCategory =
  | "weird_dreamcore_analog_horror_liminal"
  | "fun_crap";

export interface InstagramPage {
  readonly url: string;
  category: InstagramCategory;
}

// api.ts
import type { InstagramPage } from "./instagram-page.js";

const INSTAGRAM_API_URL =
  "https://training.dercetech.com/trainings/typescript-basics/api/random-insta-page-I-like.php";

export async function fetchFavoriteInstagramPage():
  Promise<InstagramPage> {
  const response = await fetch(INSTAGRAM_API_URL);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

// render.ts
import type { InstagramPage } from "./instagram-page.js";

export function renderInstagramPage(page: InstagramPage): HTMLAnchorElement {
  const link = document.createElement("a");
  link.href = page.url;
  link.textContent = `${page.category} — ${page.url}`;
  link.target = "_blank";
  link.rel = "noreferrer";
  return link;
}

// main.ts
import { fetchFavoriteInstagramPage } from "./api.js";
import { renderInstagramPage } from "./render.js";

const result = document.querySelector<HTMLElement>("#instagram-result");
const button = document.querySelector<HTMLButtonElement>("#another-page");

async function showAnotherPage() {
  if (!result) return;
  const page = await fetchFavoriteInstagramPage();
  result.replaceChildren(renderInstagramPage(page));
}

button?.addEventListener("click", showAnotherPage);
showAnotherPage();

Les types décrivent ici la réponse attendue, mais ne transforment et ne valident pas le JSON reçu. Une validation d’exécution serait une étape supplémentaire.

Checkpoint

Vous savez typer un petit module navigateur complet : réponse d’API, données, DOM, événements, imports et fetch.

x1 Extra 1 : Génériques & overloads (≈ 35–45 min)

Cette partie rassemble les syntaxes plus complexes retirées de la section 4. Elles sont utiles, mais ne sont pas nécessaires pour manipuler les collections et les objets du quotidien.

x1.1 Lire les types génériques courants

interface CourseTask {
  readonly id: number;
  title: string;
  state: "todo" | "doing" | "done";
}

const queuedTasks: Array<CourseTask> = [
  { id: 1, title: "Installer Vite", state: "done" }
];

async function loadTasks(): Promise<CourseTask[]> {
  return queuedTasks;
}
  • Array<CourseTask> signifie « tableau dont chaque élément est une CourseTask » ; c’est l’équivalent de CourseTask[].
  • Promise<CourseTask[]> signifie « promesse qui fournira plus tard un tableau de tâches ».

x1.2 Écrire une fonction générique

Un générique relie le type reçu au type retourné. Ici, T est choisi séparément à chaque appel.

function first<T>(items: readonly T[]): T | undefined {
  return items[0];
}

const firstTask = first(tasks);
// CourseTask | undefined

const firstTitle = first(["Vite", "TypeScript"]);
// string | undefined

Le même principe peut conserver précisément un identifiant chaîne ou nombre :

function formatId<T extends string | number>(id: T): T {
  return id;
}

const numericId = formatId(123);
// 123

const textId = formatId("abc");
// "abc"

let idFromApi: number = 123;
const apiId = formatId(idFromApi);
// number

extends string | number limite les arguments autorisés. formatId(true) est refusé, tandis que le résultat conserve le type précis de l’argument accepté.

x1.3 Déclarer des appels distincts avec des overloads

Les overloads décrivent explicitement plusieurs signatures publiques avant une implémentation commune.

function formatId(id: number): number;
function formatId(id: string): string;

function formatId(id: number | string) {
  return id;
}

const numericId = formatId(123);
// number

const textId = formatId("abc");
// string
  • Générique : une même relation entrée → sortie fonctionne pour de nombreux types, comme first(tasks) et first(titles).
  • Overloads : une petite liste d’appels publics doit être documentée séparément, comme formatId(number) et formatId(string).
  • Union : utilisez-la seule si le résultat n’a pas besoin de conserver le lien précis avec l’argument.

Tester séparément

Les deux exemples déclarent formatId. Testez d’abord la version générique, puis remplacez-la par la version avec overloads. Survolez les résultats et essayez formatId(true) dans chaque version.

Checkpoint

Vous savez lire Array<T> et Promise<T>, écrire une fonction générique qui conserve son type et reconnaître le cas où des overloads rendent l’API plus explicite.

x2 Extra 2 : as const, utility types & tsconfig (≈ 45–60 min)

x2.1 Dériver un type avec as const

Voici les alternatives plus techniques annoncées en section 4.5. as const conserve les valeurs littérales au lieu de les élargir en simples string ou number.

Une liste de chaînes que l’on veut aussi parcourir

const loadKinds = ["loading", "ready", "error"] as const;
type LoadKind = typeof loadKinds[number];
// "loading" | "ready" | "error"

const loadKindOptions = loadKinds.map((kind) => ({
  value: kind,
  label: kind
}));

Des constantes nommées sans enum

const LoadKind = {
  Loading: "loading",
  Ready: "ready",
  Error: "error"
} as const;

type LoadKindValue = typeof LoadKind[keyof typeof LoadKind];
// "loading" | "ready" | "error"

const currentKind: LoadKindValue = LoadKind.Ready;

La même technique avec des nombres

const animationPhases = [0, 1, 2] as const;
type AnimationPhaseValue = typeof animationPhases[number];
// 0 | 1 | 2

const AnimationPhase = {
  Waiting: 0,
  Moving: 1,
  Finished: 2
} as const;

type NamedAnimationPhase =
  typeof AnimationPhase[keyof typeof AnimationPhase];
// 0 | 1 | 2

Choisissez le tableau quand vous devez parcourir les valeurs. Choisissez l’objet quand des noms comme LoadKind.Ready rendent le code plus lisible. Dans les deux cas, TypeScript dérive l’union à partir des valeurs JavaScript.

x2.2 Cinq transformations de contrat utiles

interface Task {
  readonly id: number;
  title: string;
  done: boolean;
  notes?: string;
}

type TaskPatch = Partial<Task>;
type CompleteTask = Required<Task>;
type SafeTask = Readonly<Task>;
type TaskPreview = Pick<Task, "id" | "title">;
type NewTask = Omit<Task, "id">;
  • Partial rend toutes les propriétés optionnelles.
  • Required les rend toutes obligatoires.
  • Readonly les rend toutes en lecture seule.
  • Pick conserve certaines propriétés.
  • Omit retire certaines propriétés.

Record a déjà été étudié dans la section 4 : contrairement à ces cinq transformations, il décrit un dictionnaire à partir d’un type de clé et d’un type de valeur.

x2.3 Options tsconfig qui changent le quotidien

{
  "compilerOptions": {
    "strict": true,
    "sourceMap": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true
  }
}

strict : refuser les hypothèses fragiles

function move(step) {
  return step + 8;
}
// erreur : step possède implicitement le type any

sourceMap : retrouver le fichier TypeScript quand tsc émet le JavaScript

throw new Error("Animation interrompue");
// avec une source map, la stack trace pointe vers ce fichier .ts
// sans source map, elle pointe vers le JavaScript généré

Dans notre configuration Vite avec noEmit, cette propriété ne pilote pas le build Vite : les source maps de développement sont automatiques et celles de production se configurent avec build.sourcemap dans vite.config.ts.

noUncheckedIndexedAccess : un index peut manquer

const firstTask = tasks[0];
// CourseTask | undefined

if (firstTask) {
  console.log(firstTask.title);
}

exactOptionalPropertyTypes : absente ne veut pas dire undefined

interface Draft {
  note?: string;
}

const emptyDraft: Draft = {}; // accepté
const unclearDraft: Draft = { note: undefined }; // refusé

// Pour autoriser explicitement la seconde forme :
interface DraftWithUndefined {
  note?: string | undefined;
}

noUnusedLocals : repérer le code abandonné

function renderTask(task: CourseTask) {
  const debugLabel = `task-${task.id}`;
  return task.title;
}
// erreur : debugLabel est déclaré mais jamais utilisé

noUnusedParameters : nettoyer les signatures

function formatTitle(title: string, uppercase: boolean) {
  return title;
}
// erreur : uppercase n’est jamais utilisé

// Corriger en l’utilisant ou en le retirant de la signature.

Exercice de migration

Ouvrez x2_utilities/.

Prenez le module d’intégration du jour 3 fourni dans le dossier. Faites-le passer en TypeScript proprement. Utilisez les utility types et les options tsconfig seulement quand elles servent le module.

La version de référence se trouve dans x2_utilities-solved/.

Checkpoint

Vous savez transformer un contrat avec cinq utility types, activer les options utiles et migrer un module JavaScript propre sans changer son comportement.