Accueil / Articles / Comportements de TypeScript qui surprennent les développeurs expérimentés, et pourquoi

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.

4295 mots

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.
  • Préférez unknown à any et satisfies à as lorsque votre objectif est de vérifier, et non de surcharger.
  • Utilisez les unions discriminées et 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

  • Dix patterns TypeScript qui transforment les bugs en temps de exécution en erreurs de compilation — Découvrez dix techniques TypeScript, allant des unions discriminées et de satisfies aux types branded et infer, qui permettent au compilateur de rejeter les états invalides avant que votre code ne soit déployé.
  • Ce que le compilateur TypeScript détecte alors que JavaScript le laisse passer — Comparez JavaScript et TypeScript côte à côte : inférence de types, annotations, primitives, any, unions et fonctions typées, ainsi que ce que tsc signale réellement et génère.