Comportements de TypeScript qui surprennent les développeurs expérimentés, et pourquoi
Typage structuré, vérifications de propriétés supplémentaires, les mots-clés as const et satisfies, types conditionnels et mappés, ainsi que les principes de conception qui transforment tout cela en code plus sûr.
La plupart des développeurs commencent par considérer TypeScript comme du JavaScript avec des annotations, avant de se heurter à des comportements qui ne correspondent pas à cette vision : un objet possédant des champs supplémentaires est accepté en un endroit et rejeté ailleurs, une assertion de type ne « convertit » rien, et readonly permet toujours à une valeur imbriquée de changer. Au-delà des annotations de base se trouve un langage au niveau des types, doté de ses propres règles en matière de compatibilité, d’inférence et de calcul. Ce guide explique ces surprises une par une, allant de l’effacement en temps d’exécution et du typage structuré à infer, aux types de littéraux de template et à satisfies, avant de les transformer en principes de conception pratiques que vous pouvez appliquer à un véritable codebase.
Les types cessent d’exister lorsque le code s’exécute
Voici une interface et un objet annoté avec elle :
interface User {
id: number;
name: string;
}
const user: User = {
id: 1,
name: "Lakhveer"
};
Il est tentant de penser que le programme en exécution sait que user est un User. Ce n’est pas le cas. La compilation supprime l’interface, et ce qui reste est essentiellement ceci :
const user = {
id: 1,
name: "Lakhveer"
};
Aucune valeur User n’existe en temps de exécution. TypeScript effectue d’abord une analyse statique, puis génère du JavaScript, et ce n’est que ce JavaScript qui parvient au moteur :
TypeScript
↓
Type Checking
↓
JavaScript Generation
↓
Browser / Node.js
Les types informent le compilateur ; ce ne sont pas des objets en temps de exécution. La conséquence pratique est que TypeScript ne valide jamais les données provenant de l’extérieur de votre programme. L’annotation ci-dessous ne fait que confirmer ce que le serveur renvoie ; rien ne le vérifie :
const response: User = await fetch("/api/user")
.then(res => res.json());
Pour les données externes, une validation en temps de exécution est nécessaire. Une bibliothèque de schémas telle que Zod décrit la structure une fois et la vérifie lorsque les données arrivent :
const UserSchema = z.object({
id: z.number(),
name: z.string()
});
const user = UserSchema.parse(data);
Une façon utile de se souvenir de cette distinction : le compilateur protège votre code, tandis que la validation en temps de exécution protège votre application. Le guide du blog sur le partage d’un schéma Zod entre un frontend React et un backend Node montre comment appliquer cela des deux côtés.
any, unknown et la charge de la preuve
any désactive les vérifications
Avec any, chacune de ces opérations absurdes se compile sans problème :
let value: any = "hello";
value.foo.bar.baz();
value();
value.notARealProperty;
any demande en fait au compilateur de vous faire confiance et d’arrêter de vérifier. C’est pourquoi un paramètre de ce type élimine silencieusement la majeure partie des avantages de TypeScript :
function processUser(user: any) {
console.log(user.name);
}
unknown exige des preuves
unknown accepte également n’importe quelle valeur :
let value: unknown = "hello";
Mais l’utiliser directement échoue :
value.foo;
Il faut d’abord le restreindre, par exemple à une chaîne de caractères :
if (typeof value === "string") {
console.log(value.toUpperCase());
}
ou à un nombre :
if (typeof value === "number") {
console.log(value.toFixed(2));
}
La différence d’attitude est facile à résumer :
any
↓
"Trust me"
unknown
↓
"Prove it first"
Ainsi, lorsque vous ne connaissez vraiment pas le type d’une valeur, optez pour
unknown
plutôt que
any
et laissez le compilateur vous forcer à prouver ce que vous avez avant de l’utiliser.
La compatibilité concerne la structure, pas les noms
Les développeurs venus de Java, C# ou C++ sont souvent surpris que cette affectation soit autorisée :
interface User {
name: string;
}
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
TypeScript utilise un typage structural : la compatibilité dépend des propriétés qu’a une valeur, et non de ce pour quoi elle a été déclarée. User a besoin uniquement de ceci :
name: string
et employee en possède également, plus d’autres. Le raisonnement appliqué par le compilateur est le suivant :
User requires:
name: string
employee has:
name: string
salary: number
Therefore:
employee satisfies User
ou, en tant que flux de décision :
Required properties
↓
Does object contain them?
↓
Yes
↓
Compatible
Les vérifications excessives de propriétés s’appliquent aux littéraux neufs
Voici la particularité : l’écriture d’un champ supplémentaire directement dans un littéral d’objet est rejetée :
interface User {
name: string;
}
const user: User = {
name: "Lakhveer",
salary: 100000
};
avec une erreur du type :
Object literal may only specify known properties
Pourtant, attribuer les mêmes données via une variable intermédiaire fonctionne :
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
La raison est que TypeScript effectue des vérifications excessives de propriétés sur les littéraux d’objet neufs, c’est-à-dire ceux écrits directement au moment de l’affectation, afin de prévenir les fautes d’orthographe. Cette vérification est distincte de la compatibilité structurelle. Ainsi, l’affirmation « TypeScript rejette les propriétés supplémentaires » n’est vraie que pour les littéraux ; une fois qu’un objet a été affecté via une variable, les champs supplémentaires sont acceptés.
Types littéraux et unions dérivées
Valeurs exactes plutôt que types larges
Une variable peut être restreinte à des valeurs spécifiques :
let direction: "left" | "right";
direction = "left";
Ainsi, cette affectation échoue :
direction = "up";
La déclaration ne précise pas
direction: string
Elle indique
direction must be EXACTLY:
"left"
OR
"right"
Cette précision améliore considérablement les APIs. Une fonction de déploiement ne peut accepter que des environnements connus :
type Environment =
| "development"
| "staging"
| "production";
function deploy(env: Environment) {
// ...
}
Et une faute de frappe devient une erreur de compilation plutôt qu’un déploiement échoué :
deploy("testing");
as const modifie ce qui est inféré
Un littéral d’array ordinaire de chaînes :
const colors = ["red", "blue", "green"];
est inféré comme
string[]
Ajout de as const :
const colors = ["red", "blue", "green"] as const;
produit plutôt une tuple de lecture seule de types littéraux :
readonly ["red", "blue", "green"]
À partir de cette tuple, on peut dériver une union en indexant avec number :
type Color = typeof colors[number];
Cela donne :
type Color = "red" | "blue" | "green";
Cela élimine une duplication fréquente. Sans cela, il faut gérer à la main une union et un tableau qui doivent rester synchronisés :
type Color = "red" | "blue" | "green";
const colors: Color[] = [
"red",
"blue",
"green"
];
Avec cela, le tableau devient la seule source de vérité et le type est déterminé automatiquement :
const colors = [
"red",
"blue",
"green"
] as const;
type Color = typeof colors[number];
Le principe se généralise : lorsque le système de types peut déduire des informations, il ne faut pas les écrire deux fois.
Mots-clés qui fonctionnent au niveau du type
typeof a deux fonctions
En JavaScript,
typeof value
est un opérateur en temps de exécution. Par exemple,
typeof "hello";
donne pour résultat
"string"
Au niveau du type, TypeScript réutilise ce mot-clé pour capturer le type statique d’une variable :
const user = {
id: 1,
name: "Lakhveer"
};
type User = typeof user;
Ici
User
devient
{
id: number;
name: string;
}
Même mot-clé, deux contextes :
Runtime:
typeof value
Type system:
typeof variable
keyof transforme les clés en une union
Étant donné une interface,
interface User {
id: number;
name: string;
email: string;
}
Ce type
type UserKeys = keyof User;
est
"id" | "name" | "email"
En combinaison avec les génériques, cela permet d’écrire un accesseur de propriété qui n’accepte que des clés réelles :
function getValue<T, K extends keyof T>(
object: T,
key: K
) {
return object[key];
}
L’appel avec une clé existante fonctionne :
const user = {
id: 1,
name: "Lakhveer"
};
getValue(user, "name");
alors qu’une clé manquante est rejetée au moment de la compilation :
getValue(user, "salary");
Le type de retour est également précis : T[K] correspond au type de cette propriété en particulier.
Les génériques relient les valeurs entre elles
Le générique classique se contente de renvoyer ce qu’il reçoit :
function identity<T>(value: T): T {
return value;
}
Les génériques deviennent plus intéressants lorsqu’ils lient plusieurs valeurs ensemble. Ici, les deux arguments doivent partager le même type :
function pair<T>(first: T, second: T): [T, T] {
return [first, second];
}
donc cet appel est accepté :
pair(10, 20);
Mais celui-ci échoue, car T est déduit du premier argument comme étant de type number et une chaîne ne peut pas y être assignée :
pair(10, "hello");
Les génériques peuvent également relier honnêtement l’entrée à la sortie, y compris dans le cas vide :
function first<T>(items: T[]): T | undefined {
return items[0];
}
Pour une appelation telle que
const numbers = first([1, 2, 3]);
le compilateur affiche le résultat comme
number | undefined
Calcul des types
Les types conditionnels sont un if au niveau du type
Un type conditionnel choisit entre deux résultats en fonction d’une vérification :
type IsString<T> =
T extends string
? true
: false;
Ainsi
type A = IsString<string>;
il se résout en
true
et
type B = IsString<number>;
il se résout en
false
Conceptuellement, vous écrivez ceci, sauf que cela s’exécute dans le compilateur plutôt que dans votre programme :
if T is string
return true
else
return false
infer extrait des parties d’un type
Dans un type conditionnel, infer introduit une variable de type que TypeScript remplit par correspondance. Cela réimplémente le ReturnType intégré :
type ReturnTypeOf<T> =
T extends (...args: any[]) => infer R
? R
: never;
Lorsqu’il est appliqué à une fonction réelle, il extrait le type de l’objet retourné :
function getUser() {
return {
id: 1,
name: "Lakhveer"
};
}
type User = ReturnTypeOf<typeof getUser>;
Le modèle mental est une correspondance de motifs :
Function
↓
infer R
↓
Extract return type
De nombreux types utilitaires standards sont construits exactement de cette manière.
Les types mappés transforment chaque propriété
À partir d’une interface,
interface User {
id: number;
name: string;
email: string;
}
un type mappé itère sur ses clés pour produire une version lisible uniquement :
type ReadonlyUser = {
readonly [K in keyof User]: User[K];
};
ou une version optionnelle :
type OptionalUser = {
[K in keyof User]?: User[K];
};
au lieu de réécrire chaque champ manuellement :
id?: number;
name?: string;
email?: string;
Les types utilitaires sont construits à partir de ces éléments
TypeScript fournit une bibliothèque de tels outils :
Partial<T>
Required<T>
Readonly<T>
Pick<T, K>
Omit<T, K>
Record<K, T>
Exclude<T, U>
Extract<T, U>
NonNullable<T>
ReturnType<T>
Parameters<T>
Avec un modèle comme celui-ci,
interface User {
id: number;
name: string;
email: string;
}
Pick conserve les clés sélectionnées :
type UserPreview = Pick<User, "id" | "name">;
produisant
{
id: number;
name: string;
}
et Omit les supprime :
type UserWithoutEmail = Omit<User, "email">;
Pour en savoir plus, consultez le guide du blog sur les types utilitaires intégrés de TypeScript.
never, narrowing et guards
never prouve que vous avez géré tout le cas
never est le type d’une valeur qui ne peut exister, comme le résultat d’une fonction qui lance toujours une erreur :
function fail(message: string): never {
throw new Error(message);
}
Son véritable pouvoir se manifeste dans les vérifications d’épuisement. Prenons une union de statuts :
type Status =
| "loading"
| "success"
| "error";
et un switch dont le branchement default transmet la valeur à une fonction qui n’accepte que never :
function handleStatus(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
function assertNever(value: never): never {
throw new Error("Unexpected value: " + value);
}
Lorsque chaque cas a été traité, la valeur de status a déjà été réduite à never au moment où elle atteint la case default, ce qui permet aux vérifications de type d’être effectuées. Supposons maintenant que quelqu’un étende cette union :
type Status =
| "loading"
| "success"
| "error"
| "cancelled";
Le cas non traité "cancelled" atteint assertNever ; il ne peut pas être assigné à never, et le compilateur indique tous les switches qui doivent être mis à jour.
Réduction suivant le flux de contrôle
Le compilateur suit comment les vérifications modifient les valeurs possibles d’une variable :
function print(value: string | number) {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value.toFixed(2));
}
}
Dans la première branche,
value
on sait que c’est
string
et dans la seconde, c’est
number
Guardes de type personnalisés
Vous pouvez apprendre au compilateur à reconnaître vos propres types en utilisant une fonction dont le type de retour est un prédicat de type, value is User :
interface User {
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"name" in value
);
}
Après que la vérification a réussi,
const data: unknown = getData();
if (isUser(data)) {
console.log(data.name);
}
le compilateur traite la valeur comme
data: User
dans le bloc. Gardez à l’esprit que le compilateur fait entièrement confiance au prédicat. Cette vérification ne s’assure que name existe, pas qu’il s’agit d’une chaîne de caractères ; par conséquent, une vérification négligente revient en fait à une assertion non vérifiée.
Modélisation de l’état avec des unions discriminées
Une approche courante mais insuffisante consiste à placer toutes les possibilités dans un seul objet doté de champs optionnels :
interface State {
status: string;
data?: User;
error?: string;
}
Une union discriminée modélise chaque état séparément, identifié par status:
type State =
| {
status: "loading";
}
| {
status: "success";
data: User;
}
| {
status: "error";
error: string;
};
Le passage en fonction selon l’étiquette limite chaque branche aux seuls champs qui y existent :
function render(state: State) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.name;
case "error":
return state.error;
}
}
Cela élimine les contradictions telles que
status = success
error = "Something went wrong"
ce que la version non restreinte permet volontiers. Le principe directeur est de modéliser des états valides plutôt que d’autoriser des états invalides et de vérifier ceux-ci partout.
remplit les conditions de vérification sans surcharger
satisfies vérifie que une expression correspond à un type tout en conservant le type déduit par l’expression elle-même :
const config = {
port: 3000,
host: "localhost"
} satisfies {
port: number;
host: string;
};
Cela est idéal pour les objets de configuration, où l’on souhaite effectuer des vérifications tout en préservant les valeurs littérales et les clés exactes. Comparez avec une assertion :
const config = {...} as Config;
as indique au compilateur de traiter la valeur comme ce type, et il acceptera beaucoup de choses sur votre parole. satisfies demande plutôt au compilateur de confirmer la conformité. Lorsque votre intention est la validation plutôt que de surcharger le vérificateur, préférez
satisfies
plutôt que
as
Limites qui prennent les gens au dépourvu
readonly est superficiel
Considérons un type ayant des propriétés en lecture seule, dont l’une est un objet :
type User = {
readonly name: string;
readonly address: {
city: string;
};
};
La réaffectation de la propriété de niveau supérieur est bloquée :
user.name = "New Name";
mais la modification d’un champ à l’intérieur de l’objet imbriqué reste autorisée :
user.address.city = "Indore";
readonly s’applique uniquement à la propriété qu’il marque, et non de manière récursive. L’immutabilité profonde nécessite un type mappé récursif ou un mécanisme en temps de exécution, et notez que Object.freeze est également superficiel.
Les assertions ne convertissent pas les valeurs
Cette double assertion se compile :
const value = "123" as unknown as number;
mais rien n’est converti. En temps de exécution,
typeof value
on obtient toujours
string
Si vous avez besoin d’un nombre, effectuez la conversion explicitement :
const value = Number("123");
Les assertions modifient ce que le compilateur pense, jamais la valeur réelle.
Optionnel n’est pas toujours synonyme de non défini
Une propriété optionnelle :
interface User {
name?: string;
}
signifie généralement que la clé peut être absente, donc un objet vide
{}
est valide, tout comme
{
name: "Lakhveer"
}
Mais le fait qu’un élément soit explicite
{
name: undefined
}
soit autorisé dépend de la configuration. Activer
{
"exactOptionalPropertyTypes": true
}
permet au compilateur de distinguer les deux. Cela est important pour les APIs où
property missing
et
property explicitly undefined
ont des significations différentes, par exemple dans une requête PATCH où l’absence d’un champ signifie « laisser inchangé » tandis qu’une valeur explicite signifie « le supprimer ».
L’indexation peut cacher les valeurs non définies
TypeScript ne cherche pas délibérément à empêcher chaque erreur en temps de exécution. Lire au-delà de la fin d’un tableau :
const numbers = [1, 2, 3];
const value = numbers[100];
est, par défaut, traité comme s’un nombre était toujours présent. Activer
{
"noUncheckedIndexedAccess": true
}
permet l’accès
numbers[100]
rappeler en tant que
number | undefined
ce qui vous oblige à gérer le cas manquant.
Types de chaînes et types relationnels
Types de littéraux de template
TypeScript peut créer des types de chaînes à partir d’autres types de chaînes :
type EventName =
`user:${"created" | "updated" | "deleted"}`;
ce qui se transforme en
"user:created"
"user:updated"
"user:deleted"
La même technique peut décrire des routes :
type HttpMethod = "GET" | "POST";
type Endpoint =
`${HttpMethod} /users`;
donc les valeurs valides sont
"GET /users"
"POST /users"
Combinaison des éléments en API calculées
Ces fonctionnalités se combinent :
keyof
typeof
conditional types
mapped types
template literals
infer
generics
Commencez par un tableau reliant les noms d’événements aux types de charge utile :
type EventMap = {
userCreated: {
id: number;
};
userDeleted: {
id: number;
};
};
Un émetteur générique peut alors associer chaque nom d’événement à sa charge utile en utilisant keyof et l’accès par index :
class EventEmitter<Events extends Record<string, unknown>> {
on<K extends keyof Events>(
event: K,
callback: (payload: Events[K]) => void
) {
// ...
}
emit<K extends keyof Events>(
event: K,
payload: Events[K]
) {
// ...
}
}
L’émission d’un événement connu avec la bonne charge utile se compile :
const emitter =
new EventEmitter<EventMap>();
emitter.emit("userCreated", {
id: 1
});
alors que la mauvaise charge utile est rejetée :
emitter.emit("userCreated", {
name: "Lakhveer"
});
Le compilateur comprend désormais la relation entre le nom d’un événement et les données qui doivent l’accompagner.
Démarrage strict
Un projet professionnel devrait généralement commencer par :
{
"compilerOptions": {
"strict": true
}
}
Cette seule flag active une série de vérifications, notamment :
strictNullChecks
noImplicitAny
strictFunctionTypes
strictPropertyInitialization
useUnknownInCatchVariables
Elle active également des options telles que strictBindCallApply et noImplicitThis. Ajouter des mesures de sécurité uniquement après l’apparition d’erreurs est bien plus coûteux que de laisser le compilateur servir de première ligne de défense.
Rendre les états invalides irreprésentables
Considérez un cycle de vie d’une requête modélisé sous forme de union :
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };
Comparez-le à une conception basée sur des flags et des champs optionnels :
interface RequestState {
loading: boolean;
data?: User;
error?: string;
}
La seconde approche permet des situations absurdes telles que
{
loading: true,
data: user,
error: "Something failed"
}
alors que le premier rend la construction de ces combinaisons impossible. S’il existe un principe de conception à retenir de TypeScript, c’est bien celui-ci. Pour en savoir plus, consultez la modélisation de domaines en TypeScript au-delà des annotations de base.
Principes de conception pour TypeScript en production
Connaître les fonctionnalités ne revient pas à bien concevoir avec elles. Les principes suivants portent sur la manière de structurer les systèmes.
Considérer any comme une solution de dernier recours
Au lieu de
function process(data: any) {
// ...
}
préférer
function process(data: unknown) {
// validate/narrow first
}
et, lorsque vous connaissez sa structure, encore mieux
function process(data: User) {
// ...
}
Utilisez any uniquement lorsque vous comprenez exactement ce que vous abandonnez.
Laissez l’inférence gérer ce qui est évident
Des annotations de ce type ajoutent du bruit :
const name: string = "Lakhveer";
const age: number = 28;
Le compilateur sait déjà :
const name = "Lakhveer";
const age = 28;
Conservez les types explicites là où ils décrivent un contrat.
Laissez les types transmettre l’intention
Une chaîne simple ne dit pas grand-chose :
function process(value: string) {}
Un type nommé indique ce que la valeur signifie :
type UserId = string;
function processUser(userId: UserId) {}
Une précaution : un alias comme UserId = string décrit l’intention mais ne vous empêche pas de passer un ProductId là où un UserId est attendu, puisque les deux ne sont que des chaînes. Si leur confusion représente un risque réel, un type personnalisé assure ce contrôle.
Gardez les types proches du domaine
Une signature composée de chaînes brutes :
function createOrder(
userId: string,
productId: string,
status: string
) {}
devient beaucoup plus claire avec des types de domaine :
type OrderStatus =
| "pending"
| "paid"
| "cancelled";
function createOrder(
userId: UserId,
productId: ProductId,
status: OrderStatus
) {}
Désormais, le compilateur comprend votre vocabulaire métier, et non seulement des types primitifs.
Préférez les unions aux flags booléens
Les booléens indépendants permettent des combinaisons impossibles :
interface State {
loading: boolean;
success: boolean;
error: boolean;
}
Une union ne permet qu’un seul état à la fois :
type State =
| "loading"
| "success"
| "error";
Passer à une union discriminée lorsque chaque état a besoin de ses propres données.
Vérifier aux frontières du système
Le compilateur ne peut pas garantir la fiabilité de tout ce qui arrive de l’extérieur :
API
Database
User input
Environment variables
Files
Third-party services
JSON
Local storage
Considérer tout cela comme non fiable et le faire passer par un seul pipeline :
External data
↓
Runtime validation
↓
Trusted typed data
↓
Application logic
Les données validées deviennent des données typées fiables, et seules celles-ci atteignent la logique de l’application.
Éviter le sur-ingénierie
On peut créer des types extrêmement complexes, mais une signature que
type Something<T, U, V, X extends ...> = ...
personne sur l’équipe ne parvient à expliquer six mois plus tard constitue une dette technique. Les types doivent rendre le code plus clair, et non démontrer de l’ingéniosité.
Concevoir des API pour une utilisation correcte
Il est facile de se tromper lorsqu’on utilise plusieurs flags positionnels :
createUser(
"Lakhveer",
"admin",
true,
false,
undefined
);
Un objet options de type défini est auto-descriptif et offre un complétion automatique bien meilleure :
createUser({
name: "Lakhveer",
role: "admin",
active: true
});
Composons plutôt que de créer des interfaces géantes
Une seule interface avec des dizaines de champs
interface User {
// 50 properties
}
est plus difficile à comprendre que des concepts plus petits combinés par intersection :
type Identifiable = {
id: string;
};
type Timestamped = {
createdAt: Date;
updatedAt: Date;
};
type User =
Identifiable &
Timestamped & {
name: string;
};
Faites du compilateur une partie de votre stratégie de test
Les types ne remplacent pas les tests, mais ils éliminent des catégories entières de bogues avant même que les tests ne soient exécutés. Étant donné
type PaymentStatus =
| "pending"
| "paid"
| "failed";
l’ajout d’un nouveau membre tel que
"refunded"
cela, combiné à des vérifications d’exhaustivité, révélera tous les endroits où on a oublié de le gérer.
Séparez dans votre esprit le temps de compilation et le temps d’exécution
Demandez-vous dans quel niveau vous travaillez. Ceci concerne uniquement le temps de compilation :
interface User {
id: number;
}
Ceci est une vérification en temps de exécution :
if (typeof value === "object") {
}
Et ceci est une validation en temps de exécution des données externes :
UserSchema.parse(data);
Lire le JavaScript généré
Lorsque le comportement est confus, demandez quel JavaScript le code transforme. Comprendre les deux niveaux explique la plupart des surprises.
Maîtriser profondément JavaScript
TypeScript s’appuie sur JavaScript, donc les fondamentaux restent importants :
Closures
Promises
Event Loop
Prototypes
this
Modules
Destructuring
Async/Await
Objects
Arrays
Functions
Hoisting
Scopes
Concevoir tsconfig.json de manière réfléchie
N’importez pas une configuration sans réflexion. Comprenez ce que chaque option apporte et coûte :
{
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
Chaque option modifie l’équilibre entre sécurité et commodité ; noImplicitOverride, par exemple, exige l’usage de override pour toute méthode qui remplace une méthode d’une classe de base.
Un modèle mental en couches
Il est utile d’imaginer TypeScript comme deux couches parallèles : le JavaScript en temps de exécution et un système de types en temps de compilation, les fonctionnalités au niveau des types s’appuyant les unes sur les autres :
TypeScript
│
┌────────────┴────────────┐
│ │
JavaScript Type System
│ │
Runtime Behavior Compile-Time Safety
│ │
Browser / Node Type Relationships
│
┌──────────┼──────────┐
│ │ │
Generics Unions Inference
│ │ │
keyof never conditional
│ │ │
mapped guards infer
│ │ │
└──────────┴──────────┘
Vu de cette manière, TypeScript cesse d’avoir l’air d’un amas de règles syntaxiques pour devenir un langage permettant de décrire les relations entre les valeurs : quelles valeurs sont autorisées, comment les objets se rapportent entre eux, quels états peuvent survenir, quelles fonctions acceptent et retournent des valeurs, et quels cas restent non gérés.
Points clés
Les fonctionnalités les plus importantes à maîtriser ne sont pas les plus spectaculaires :
Generics
Unions
Narrowing
Inference
keyof
typeof
Mapped Types
Conditional Types
infer
Discriminated Unions
never
unknown
satisfies
Template Literal Types
- Les types sont effacés en temps de exécution, donc les données externes nécessitent toujours une validation.
- La compatibilité est structurale, avec un contrôle supplémentaire uniquement pour les littéraux d’objet nouveaux.
as const, typeof, keyof, les types conditionnels et les types mappés vous permettent de dériver des types plutôt que de les dupliquer.unknown à any et satisfies à as lorsque votre objectif est de vérifier, et non de surcharger.never afin que le compilateur puisse vous indiquer si un état peut réellement se produire.L’objectif n’est pas de disposer des types les plus sophistiqués, mais de créer du code pour lequel le compilateur peut répondre « cet état peut-il survenir ? » avant même que le programme ne s’exécute. Utilisez TypeScript pour concevoir du code plus sûr, et non simplement pour décrire le code que vous avez déjà écrit.
Lectures complémentaires
- Six techniques TypeScript qui transforment les types en véritable prévention des bogues — Découvrez comment satisfies, les unions étiquetées, never checks, unknown, les types dérivés et les IDs marqués permettent à TypeScript de détecter des bogues réels au moment de la compilation plutôt qu’en production.
- Modélisation des domaines en TypeScript : au-delà des annotations de type basiques — Apprenez des habitudes pratiques en TypeScript — allant de unknown vs any aux unions discriminées et à satisfies — qui vous aident à modéliser des états valides plutôt que simplement à étiqueter des données.