Typescript
Exercice 1 — Annoter et laisser deviner
let age = 31
let firstname = "Julien"
let isAdmin = false
let city = "Clermont"
city = "Lyon"
Trois erreurs : "31" n’est pas un number, et 1 n’est pas un boolean
(Typescript refuse la conversion implicite que Javascript accepterait).
city = 63 échoue aussi, alors qu’aucun type n’est écrit : c’est l’inférence.
Toutes les annotations de l’énoncé étaient du bruit. Typescript déduit le type de la valeur initiale, et une annotation qui répète ce qu’il sait déjà se contente de vieillir mal.
En survolant city, on lit string — pas "Clermont". La nuance compte :
let ville = "Clermont" // string : on pourra réaffecter une autre chaîne
const cite = "Clermont" // "Clermont" : la valeur ne changera jamais
Là où l’annotation reste utile : quand il n’y a pas de valeur initiale
(let resultat: number), quand on veut un type plus large que la valeur
(let statut: string = "actif"), et sur les signatures de fonction, qui
sont un contrat.
Exercice 2 — Tableau ou tuple ?
const scores: number[] = [1, 2, 3]
const scoresBis: Array<number> = [1, 2, 3] // exactement le même type
const names: string[] = ["Alice", "Bob"]
// names.push(42) ❌ Argument of type 'number' is not assignable to 'string'
const person: [string, number] = ["Julien", 31]
// const inverse: [string, number] = [31, "Julien"] ❌ dans cet ordre, non
number[] et Array<number> sont deux écritures du même type. Un tableau,
c’est « n fois la même chose, longueur libre » ; un tuple, c’est « ces
types-là, dans cet ordre, et pas un de plus ».
Sauf que :
const coordinates: [number, number] = [45.7, 3.1]
coordinates.push(99) // ✅ accepté — le tuple a maintenant trois éléments
C’est un trou connu. Le type tuple hérite des méthodes de Array, et son
push accepte l’union des types du tuple — ici number. La longueur n’est
donc vérifiée qu’à l’assignation, pas après.
readonly bouche le trou en supprimant les méthodes qui mutent :
const coordinates: readonly [number, number] = [45.7, 3.1]
// coordinates.push(99) ❌ Property 'push' does not exist on type 'readonly [number, number]'
Exercice 3 — any contre unknown
function parseWithUnknown(data: unknown): string | null {
if (typeof data === "string") return data.toUpperCase()
return null
}
1. parseWithAny(42) compile sans un mot, puis échoue à l’exécution :
TypeError: data.toUpperCase is not a function
L’erreur n’a pas disparu, elle a changé de moment. Elle est passée du build — où elle coûte dix secondes — à la production, où elle coûte un incident.
2. À l’intérieur du if, Typescript sait que data est une string : le
typeof a restreint le type, et .toUpperCase() redevient légal. C’est le
narrowing, le mécanisme qu’on retrouve à l’exercice 6.
any dit « ne vérifie rien ». unknown dit « je ne sais pas encore, prouve-le
moi d’abord ». Le second est pénible pour exactement la bonne raison.
Exercice 4 — Typer une fonction
function divide(a: number, b: number): number {
if (b === 0) throw new Error("Division par zéro")
return a / b
}
function greet(name?: string): void {
console.log(`Hello ${name ?? "World"}`)
}
Le ? de name?: string en fait un paramètre facultatif : son type est
string | undefined, et ?? fournit la valeur de repli. Attention à ne pas
écrire || ici — il remplacerait aussi la chaîne vide, qui est pourtant un
prénom passé volontairement.
3. Sans annotation de retour, Typescript infère number : le survol
affiche function divide(a: number, b: number): number. Alors pourquoi
l’écrire ?
Parce que l’inférence suit le code, y compris quand il devient faux. Le jour où
quelqu’un ajoute if (b === 0) return "erreur", la version inférée change
silencieusement de type — string | number — et l’erreur ressort chez
l’appelant, loin de la cause. La version annotée échoue à la ligne fautive.
Un throw ne change pas le type de retour : une fonction qui lève ne retourne
pas, la branche est simplement absente de l’inférence.
Exercice 5 — Décrire la forme d’un objet
// 1. Type inline : lisible une fois, insupportable trois fois
const user: { firstname: string; lastname: string; age: number } = {
firstname: "Julien",
lastname: "Usson",
age: 31,
}
// 2. et 3. La même forme, nommée et réutilisable, avec email facultatif
interface User {
firstname: string
lastname: string
age: number
email?: string
}
const julien: User = { firstname: "Julien", lastname: "Usson", age: 31 }
// 4. Un admin est un utilisateur, avec quelque chose en plus
interface Admin extends User {
permissions: string[]
}
5. Une propriété non déclarée est refusée :
Object literal may only specify known properties, and 'surnom' does not exist in type 'User'.
C’est l’excess property check, et il ne s’applique qu’aux objets littéraux assignés directement. En passant par une variable intermédiaire, il disparaît :
const brouillon = { firstname: "Ada", lastname: "Lovelace", age: 36, surnom: "AL" }
const ada: User = brouillon // ✅ accepté
Ce n’est pas une incohérence. Typescript vérifie la compatibilité :
brouillon a tout ce que User demande, donc il fait l’affaire. Le contrôle
supplémentaire sur les littéraux n’existe que pour attraper les fautes de
frappe, là où une propriété en trop ne peut être qu’une erreur.
Exercice 6 — L’union discriminée
type Circle = { kind: "circle"; radius: number }
type Square = { kind: "square"; side: number }
type Triangle = { kind: "triangle"; base: number; height: number }
type Shape = Circle | Square | Triangle
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2
case "square":
return shape.side ** 2
case "triangle":
return (shape.base * shape.height) / 2
default: {
const _exhaustif: never = shape
return _exhaustif
}
}
}
1. Dans la branche "circle", le survol de shape affiche Circle — pas
Shape. Le champ kind, typé avec des littéraux, sert de discriminant :
le tester suffit à Typescript pour éliminer les autres membres de l’union.
shape.radius n’existe que là, et il le sait.
2. Ajouter Triangle sans toucher à area ne provoque aucune erreur.
La fonction compile, et retourne undefined sur un triangle — le pire des
cas : un bug silencieux qu’aucun outil ne signale.
3. C’est ce que répare le default. Dans cette branche, Typescript a
éliminé tous les cas traités ; s’il n’en reste aucun, shape vaut never, et
l’assignation passe. S’il en reste un :
Type 'Triangle' is not assignable to type 'never'.
L’oubli devient une erreur de compilation, à la ligne exacte. C’est le motif le plus rentable de Typescript : ajouter un cas à l’union fait apparaître la liste de tout ce qu’il reste à mettre à jour.
C’est exactement la structure Warrior | Mage du mini-projet.
Exercice 7 — Une fonction pour tous les types
function first<T>(array: T[]): T | undefined {
return array[0]
}
const n = first([1, 2, 3]) // number | undefined
const s = first(["a", "b"]) // string | undefined
T n’est pas un type, c’est un paramètre de type : il est rempli à chaque
appel, à partir de l’argument. Rien n’est écrit aux points d’appel, et pourtant
n et s ont des types différents.
2. Avec any, tout compile et rien ne protège :
function first(array: any[]): any {
return array[0]
}
const n = first([1, 2, 3]) // any
n.toUpperCase() // ✅ compile. Et plante à l'exécution.
any ne fait pas que perdre l’information : il désactive la vérification en
aval, chez tous ceux qui utiliseront la valeur.
Bonus.
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key])
}
const noms = pluck([{ nom: "Alice", age: 30 }], "nom") // string[]
// pluck([{ nom: "Alice", age: 30 }], "taille") ❌ 'taille' n'est pas une clé
K extends keyof T contraint K aux clés qui existent vraiment, et T[K]
récupère le type de la valeur derrière cette clé. Une faute de frappe dans le
nom d’une propriété devient une erreur de compilation.
⚠️ array[0] sur un T[] est typé T, pas T | undefined : par défaut,
Typescript ne vérifie pas les bornes. Écrire T | undefined est donc plus
honnête que ce que le compilateur exige — c’est l’option noUncheckedIndexedAccess
qui le lui ferait exiger.
Exercice 8 — Dériver un type d’un autre
interface User {
id: number
name: string
email: string
password: string
}
type PublicUser = Omit<User, "password">
type UserPreview = Pick<User, "id" | "name">
type UserUpdate = Partial<Omit<User, "id">> & Pick<User, "id">
type ReadonlyUser = Readonly<User>
Pick garde les clés listées, Omit retire les clés listées, Partial rend
tout facultatif, Readonly rend tout immuable.
Le troisième mérite un mot : on rend facultatif tout sauf l’id, puis on
réintroduit l’id tel quel. L’intersection & fusionne les deux. Résultat :
const maj: UserUpdate = { id: 1, name: "Alice" } // ✅
// const maj: UserUpdate = { name: "Alice" } ❌ Property 'id' is missing
Et si on ajoute une propriété à User ? Aucun de ces quatre types n’est à
modifier : ils sont dérivés, donc ils suivent. C’est tout l’intérêt. Écrits à
la main, il aurait fallu penser à les mettre à jour tous les quatre — et le
jour où on en oublie un, personne ne le voit.
Seule exception à surveiller : Omit<User, "password"> ne proteste pas si la
propriété password disparaît un jour de User. Pick, lui, échoue.
Exercice 9 — Interdire la mutation au build
type Player = {
readonly id: number
readonly name: string
readonly hp: number
}
const player: Player = { id: 1, name: "Alice", hp: 100 }
// player.hp = 50 ❌ Cannot assign to 'hp' because it is a read-only property.
Pourquoi players.push(player) était-il accepté ? Parce que readonly
porte sur les propriétés de Player, pas sur le tableau qui les contient.
Player[] reste un tableau ordinaire, parfaitement mutable. Les deux
protections sont indépendantes.
const players: readonly Player[] = [player]
// players.push(player) ❌ Property 'push' does not exist on type 'readonly Player[]'.
readonly Player[] — ou ReadonlyArray<Player>, c’est le même type — retire
push, pop, splice et compagnie. map, filter et slice restent : ils
ne mutent pas, ils retournent un nouveau tableau.
1. Modifier devient donc : construire.
function damage(player: Player, amount: number): Player {
return { ...player, hp: player.hp - amount }
}
const blesse = damage(player, 30) // { id: 1, name: "Alice", hp: 70 }
Le spread recopie tout, puis hp écrase la valeur reprise. player est
intact.
⚠️ readonly est vérifié à la compilation seulement : il disparaît du
Javascript produit. Un Object.freeze() protège à l’exécution, readonly
protège pendant qu’on écrit le code. Les deux ne servent pas au même moment.
En Javascript 4, l’immutabilité était une discipline qu’on s’imposait à la main. Ici, oublier revient à ne pas compiler.
Exercice 10 — Mini-projet : un tournoi typé
Étapes 1 & 2 — Modélisation et Puissance
Nous définissons les types de base et utilisons l’union discriminante via l’attribut role pour gérer les spécificités de chaque classe.
type BasePlayer = {
readonly id: number;
readonly name: string;
readonly level: number;
readonly hp: number;
};
type Warrior = BasePlayer & {
readonly role: "WARRIOR";
readonly strength: number;
};
type Mage = BasePlayer & {
readonly role: "MAGE";
readonly mana: number;
};
type Player = Warrior | Mage;
const displayPlayer = (player: Player): void => {
switch (player.role) {
case "WARRIOR":
console.log(`[Guerrier] ${player.name} (Force: ${player.strength})`);
break;
case "MAGE":
console.log(`[Mage] ${player.name} (Mana: ${player.mana})`);
break;
}
};
const getPower = (player: Player): number => {
switch (player.role) {
case "WARRIOR":
return player.level + player.strength;
case "MAGE":
return player.level + player.mana;
}
};
Étapes 3 à 6 — Gestion des Matchs
Pour respecter la contrainte d’immuabilité, chaque modification (comme l’ajout d’un vainqueur) retourne un nouvel objet.
type Match = {
readonly id: number;
readonly player1: Player;
readonly player2: Player;
readonly winner?: Player;
};
const displayMatch = (match: Match): void => {
console.log(
`Match ${match.id}: ${match.player1.name} vs ${match.player2.name}`
);
if (match.winner) {
console.log(`Vainqueur: ${match.winner.name}`);
}
};
const createMatch = (id: number, player1: Player, player2: Player): Match => {
if (player1.id === player2.id) {
throw new Error("Un joueur ne peut pas s'affronter lui-même");
}
return { id, player1, player2 };
};
const resolveMatch = (match: Match): Match => {
if (match.winner) return match;
const p1Power = getPower(match.player1);
const p2Power = getPower(match.player2);
let winner: Player;
if (p1Power > p2Power) {
winner = match.player1;
} else if (p2Power > p1Power) {
winner = match.player2;
} else {
// Égalité de puissance : on tranche par les HP
winner =
match.player1.hp >= match.player2.hp ? match.player1 : match.player2;
}
return { ...match, winner };
};
const extractWinner = (match: Match): Player => {
if (!match.winner) {
throw new Error(`Le match ${match.id} n'a pas encore de vainqueur`);
}
return match.winner;
};
Étapes 7 & 8 — Logique du Tournoi
On utilise la récursion pour runTournament afin d’éviter les boucles while et les mutations.
const playRound = (players: readonly Player[]): readonly Player[] => {
if (players.length % 2 !== 0) {
throw new Error(
"Le nombre de joueurs doit être pair pour un tournoi à élimination directe"
);
}
// Création des paires et résolution (0-1, 2-3, etc.)
return players.reduce<readonly Player[]>((winners, player, index, array) => {
if (index % 2 === 0) {
const p1 = player;
const p2 = array[index + 1];
const match = createMatch(index, p1, p2);
const resolvedMatch = resolveMatch(match);
return [...winners, extractWinner(resolvedMatch)];
}
return winners;
}, []);
};
const runTournament = (players: readonly Player[]): Player => {
console.log(`--- Nouveau tour : ${players.length} joueurs restants ---`);
if (players.length === 0) throw new Error("Aucun joueur dans le tournoi");
if (players.length === 1) return players[0];
const nextRoundPlayers = playRound(players);
return runTournament(nextRoundPlayers);
};
Étape 9 — Refactorisation (Union Discriminante de Matchs)
C’est ici que TypeScript brille : on sépare le type Match en deux états distincts.
type BaseMatch = {
readonly id: number;
readonly player1: Player;
readonly player2: Player;
};
type PendingMatch = BaseMatch & { readonly status: "PENDING" };
type FinishedMatch = BaseMatch & {
readonly status: "FINISHED";
readonly winner: Player;
};
type MatchV2 = PendingMatch | FinishedMatch;
const createMatchV2 = (
id: number,
player1: Player,
player2: Player
): PendingMatch => {
if (player1.id === player2.id) throw new Error("ID identiques");
return { id, player1, player2, status: "PENDING" };
};
const resolveMatchV2 = (match: PendingMatch): FinishedMatch => {
const p1Power = getPower(match.player1);
const p2Power = getPower(match.player2);
const winner = p1Power >= p2Power ? match.player1 : match.player2;
return {
...match,
status: "FINISHED",
winner,
};
};
const extractWinnerV2 = (match: FinishedMatch): Player => {
// Ici, plus besoin de check "if (!match.winner)",
// car le type FinishedMatch garantit la présence du winner.
return match.winner;
};
Exemple d’exécution
const participants: readonly Player[] = [
{ id: 1, name: "Aragorn", level: 10, hp: 100, role: "WARRIOR", strength: 15 },
{ id: 2, name: "Gandalf", level: 10, hp: 80, role: "MAGE", mana: 20 },
{ id: 3, name: "Gimli", level: 8, hp: 120, role: "WARRIOR", strength: 12 },
{ id: 4, name: "Saruman", level: 9, hp: 70, role: "MAGE", mana: 18 },
];
try {
const winner = runTournament(participants);
console.log(`🏆 Le grand vainqueur est : ${winner.name}`);
} catch (e) {
console.error(e);
}