TypeScript pour un développeur PHP
Du typage PHP, poussé beaucoup plus loin.
Vous typez déjà vos propriétés, vos arguments et vos retours en PHP 8. TypeScript part du même endroit et va bien au-delà : il sait décrire « une chaîne parmi ces trois valeurs », « le même type que la clé de cet objet », ou « ce type moins ces deux champs ». Et il fait tout cela sans exister à l'exécution — c'est un vérificateur, pas un moteur.
Le modèle mental
Trois idées à intégrer avant toute syntaxe
TypeScript n'est pas « JavaScript avec des types PHP ». Trois différences structurelles conditionnent tout le reste, et les ignorer produit des heures de confusion.
1 · Les types disparaissent à la compilation
En PHP, function f(int $x) lève une TypeError à l'exécution si vous passez une chaîne. En TypeScript, rien n'est vérifié à l'exécution : tsc supprime purement et simplement les annotations et produit du JavaScript ordinaire. Si une API vous renvoie un champ manquant, TypeScript ne le verra pas — il vous avait juste promis qu'il serait là.
// Ce que vous écrivez
function total(prix: number, quantite: number): number {
return prix * quantite;
}
// Ce qui tourne dans le navigateur
function total(prix, quantite) {
return prix * quantite;
}fetch(), localStorage, un formulaire, votre API Symfony — n'est pas validée par TypeScript. Le typage d'une réponse HTTP est une promesse non vérifiée. C'est exactement la raison d'être de Zod, traité en section 16.2 · Le typage est structurel, pas nominal
En PHP, un objet satisfait un type parce qu'il implements l'interface — c'est un lien nominal, déclaré. En TypeScript, un objet satisfait un type parce qu'il en a la forme. Aucune déclaration n'est nécessaire.
Le même contrat, deux mécaniques opposées. À droite, afficher() accepte l'objet parce qu'il a les bonnes propriétés — la classe n'a jamais entendu parler de l'interface.
interface Nommable {
public function getNom(): string;
}
// Sans "implements", ceci est REFUSÉ
class Produit implements Nommable {
public function __construct(
private string $nom,
) {}
public function getNom(): string
{
return $this->nom;
}
}
function afficher(Nommable $n): void {
echo $n->getNom();
}interface Nommable {
nom: string;
}
// Aucun "implements" nécessaire
class Produit {
constructor(public nom: string) {}
}
const p = new Produit("Clavier");
afficher(p); // ✅
// Un objet littéral marche tout autant
afficher({ nom: "Souris" }); // ✅
function afficher(n: Nommable): void {
console.log(n.nom);
}C'est extrêmement libérateur une fois compris : vous décrivez des formes, pas des hiérarchies. Vous n'aurez presque jamais besoin de implements, et vos types deviennent composables.
3 · L'inférence fait 80 % du travail
En PHP, un type non déclaré est mixed. En TypeScript, le compilateur déduit le type le plus précis possible. Sur-annoter est le premier réflexe des développeurs PHP, et c'est un contre-sens : le code devient plus verbeux et moins précis.
// ❌ Redondant : TypeScript sait déjà
const nom: string = "Amar";
const total: number = 10 * 3;
const tags: string[] = ["php", "js"];
// ✅ Laissez inférer
const nom = "Amar"; // string
const total = 10 * 3; // number
const tags = ["php", "js"]; // string[]
// ✅ Annotez les FRONTIÈRES : signatures, retours d'API, état vide
function calculer(prix: number, tva: number): number {
const montant = prix * (1 + tva); // inféré : number
return montant;
}
// ✅ Annotez quand la valeur initiale ne dit rien
const [articles, setArticles] = useState<Article[]>([]);
let erreur: string | null = null;Installation & tsconfig
Le composer.json du typage
npm install -D typescriptLe compilateur, en dépendance de développement.npx tsc --initGénère un tsconfig.json commenté.npx tsc --noEmitVérifie les types sans produire de fichiers. La commande à mettre en CI.npx tsc --watch --noEmitVérification continue pendant le développement.npm create vite@latest mon-app -- --template react-tsProjet React + TypeScript prêt à l'emploi.npm install -D @types/nodeTypes pour les API Node. Les paquets @types/* sont les stubs du monde JS.Le tsconfig minimal qui vous évitera des ennuis
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
/* Le réglage le plus important */
"strict": true,
/* Fortement recommandés en plus de strict */
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
/* Confort */
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Alias d'import — évite les ../../.. */
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}| Option | Ce qu'elle change | Analogie PHP |
|---|---|---|
strict | active sept vérifications d'un coup, dont strictNullChecks | declare(strict_types=1), en beaucoup plus large |
strictNullChecks | null n'est plus assignable partout | types nullables explicites ?string |
noImplicitAny | interdit les paramètres non typés | interdire mixed implicite |
noUncheckedIndexedAccess | tab[0] devient T | undefined | aucun équivalent — c'est plus strict que PHP |
exactOptionalPropertyTypes | distingue « absent » et « défini à undefined » | aucun équivalent |
noEmit | vérifie sans compiler (Vite compile) | PHPStan sans build |
strict: true dès le premier jour sur un projet neuf. Le migrer plus tard sur un projet existant coûte dix fois plus cher. C'est exactement le même arbitrage que declare(strict_types=1) ou monter PHPStan de niveau 0 à 6.Types de base
Ce qui existe en PHP, et les cinq qui n'existent pas
| TypeScript | PHP | Note |
|---|---|---|
string | string | identique |
number | int + float | un seul type numérique : pas d'entier distinct |
bigint | int (64 bits) | pour les grands entiers |
boolean | bool | identique |
string[] | array<string> | liste homogène |
[string, number] | aucun équivalent | tuple : longueur et types fixes |
Record<string, T> | array<string, T> | objet indexé par clé |
null | null | l'absence choisie |
undefined | aucun équivalent | l'absence non initialisée — voir section 11 |
void | void | la fonction ne retourne rien d'utile |
never | never | ne retourne jamais (throw, boucle infinie) |
unknown | mixed | à narrower avant usage |
any | pas de typage du tout | désactive le compilateur — à éviter |
'draft' | 'published' | enum, approximativement | type littéral : la valeur est le type |
number est un flottant IEEE 754, comme float en PHP. Conséquence directe : ne stockez jamais un montant en euros dans un number — 0.1 + 0.2 vaut 0.30000000000000004. Travaillez en centimes (entiers) ou en chaînes, exactement comme vous le feriez avec des DECIMAL en base.unknown vs any : la distinction qui compte
// ❌ any : TypeScript ne dit plus rien, y compris sur les erreurs évidentes
function traiter(data: any) {
return data.utilisateur.nom.toUpperCase(); // compile, plante peut-être
}
// ✅ unknown : il faut prouver la forme avant d'y toucher
function traiter(data: unknown) {
// data.nom → erreur de compilation
if (typeof data === "object" && data !== null && "nom" in data) {
console.log(data.nom); // ici, TypeScript est rassuré
}
}Considérez unknown comme le mixed de PHP 8 : une valeur dont on ne sait rien et qu'il faut inspecter. any n'a pas d'équivalent PHP — c'est un interrupteur qui éteint la vérification, y compris en cascade sur tout ce qui en découle.
Tuples et tableaux
// Tableau : longueur variable, type homogène
const tags: string[] = ["php", "js"];
const tags2: Array<string> = ["php"]; // syntaxe équivalente
// Tuple : longueur fixe, types positionnels
const coord: [number, number] = [48.85, 2.35];
const paire: [string, number] = ["âge", 34];
// C'est ce que renvoie useState
const [valeur, setValeur] = useState(0); // [number, Dispatch<...>]
// Tuple nommé — purement documentaire, mais lisible
type Intervalle = [debut: Date, fin: Date];
// Lecture seule
const figé: readonly string[] = ["a", "b"];
// figé.push("c") → erreur
// as const : fige tout, y compris les valeurs
const roles = ["admin", "editeur"] as const;
// type : readonly ["admin", "editeur"]
type Role = typeof roles[number]; // "admin" | "editeur"Interfaces & type aliases
Décrire la forme d'un objet
Deux syntaxes pour un résultat presque identique. La règle simple : interface pour les objets qu'on étend, type pour tout le reste — unions, tuples, fonctions, types calculés. Si vous hésitez, type est le plus polyvalent.
interface Article {
id: number;
title: string;
slug: string;
body: string;
publishedAt: string | null; // null explicite, comme ?string en PHP
views?: number; // optionnel : number | undefined
readonly createdAt: string; // readonly comme en PHP 8.2
author: { // objet imbriqué
id: number;
name: string;
};
tags: string[];
}
// Équivalent avec type
type Article = {
id: number;
title: string;
};
// type sait faire ce que interface ne sait pas
type Id = number | string;
type Callback = (article: Article) => void;
type Paire = [string, number];
type Statut = "draft" | "published" | "archived";| Capacité | <code>interface</code> | <code>type</code> |
|---|---|---|
| Décrire un objet | oui | oui |
Union (A | B) | non | oui |
| Tuple | non | oui |
| Type de fonction | possible mais lourd | oui |
| Extension | extends | intersection & |
| Fusion automatique de deux déclarations | oui | non |
| Types calculés (mapped, conditional) | non | oui |
Composer plutôt qu'hériter
En PHP vous héritez ou vous implémentez. En TypeScript, l'intersection & fusionne deux formes — c'est la composition à l'échelle du type, et c'est le réflexe dominant.
abstract class Entite
{
public int $id;
public \DateTimeImmutable $createdAt;
}
class Article extends Entite
{
public string $title;
}
interface Publiable
{
public function publier(): void;
}
class Article extends Entite implements Publiable
{
// …
}interface Horodate {
createdAt: string;
updatedAt: string;
}
interface Identifiable {
id: number;
}
// Par extension
interface Article extends Identifiable, Horodate {
title: string;
}
// Ou par intersection — même résultat
type Article = Identifiable & Horodate & {
title: string;
};
// Composable à la volée
type ArticleAvecAuteur = Article & { author: User };Signatures d'index et objets dynamiques
// Clés inconnues, valeurs typées
interface Traductions {
[cle: string]: string;
}
// Version moderne, plus lisible
type Traductions = Record<string, string>;
// Clés contraintes — très utile
type Statut = "draft" | "published";
type Libelles = Record<Statut, string>;
const libelles: Libelles = {
draft: "Brouillon",
published: "Publié",
// il manque une clé → erreur de compilation
};
// Valeurs hétérogènes
type Config = Record<string, string | number | boolean>;Record<Statut, string> est un des gains les plus concrets de TypeScript sur PHP : ajoutez une valeur à l'union Statut, et toutes les tables de correspondance de votre code refusent de compiler tant que la nouvelle clé n'est pas traitée. C'est de l'exhaustivité garantie, gratuitement.Unions & narrowing
Le cœur de TypeScript, sans équivalent PHP
Si vous ne deviez retenir qu'une section, c'est celle-ci. Une union déclare qu'une valeur peut être de plusieurs types. Le narrowing est la capacité du compilateur à réduire cette union au fur et à mesure de vos vérifications. PHP a bien les union types depuis la 8.0, mais il n'a pas de flux d'analyse comparable.
function formater(valeur: string | number | Date): string {
if (typeof valeur === "string") {
return valeur.trim(); // ici : string
}
if (typeof valeur === "number") {
return valeur.toFixed(2); // ici : number
}
return valeur.toISOString(); // ici : Date, par élimination
}Notez le dernier return : aucune vérification, et pourtant TypeScript sait que valeur est forcément une Date. C'est l'analyse de flux — le compilateur suit vos branches comme vous le feriez mentalement.
Les six façons de narrower
// 1. typeof — pour les primitives
if (typeof x === "string") { … }
// 2. instanceof — pour les classes
if (err instanceof Error) { console.log(err.message); }
// 3. in — pour la présence d'une propriété
if ("email" in contact) { … }
// 4. Vérification de véracité
if (utilisateur) { … } // écarte null et undefined
// 5. Union discriminée — la plus puissante
if (resultat.statut === "ok") { … }
// 6. Predicate maison — une fonction qui enseigne au compilateur
function estArticle(x: unknown): x is Article {
return typeof x === "object" && x !== null && "slug" in x;
}
if (estArticle(donnees)) {
donnees.slug; // TypeScript est convaincu
}Les unions discriminées : le pattern à maîtriser
C'est la structure la plus utile de tout TypeScript, et celle qui n'a vraiment aucun équivalent PHP. Chaque variante porte un champ littéral qui l'identifie ; le compilateur s'en sert pour savoir exactement quels autres champs existent.
type EtatRequete<T> =
| { statut: "inactif" }
| { statut: "chargement" }
| { statut: "succes"; donnees: T }
| { statut: "erreur"; message: string; code: number };
function rendu(etat: EtatRequete<Article[]>) {
switch (etat.statut) {
case "inactif":
return "Prêt.";
case "chargement":
return "Chargement…";
case "succes":
// etat.donnees existe ici, et seulement ici
return `${etat.donnees.length} articles`;
case "erreur":
// etat.message et etat.code existent ici
return `Erreur ${etat.code} : ${etat.message}`;
}
}{ chargement: boolean; donnees?: T; erreur?: string } — autorise des états impossibles : chargement et erreur en même temps, succès sans données. L'union discriminée rend ces combinaisons inexprimables. C'est la même philosophie qu'un enum PHP avec des cas exclusifs, mais avec des données attachées à chaque cas.Garantir l'exhaustivité
function libelle(statut: Statut): string {
switch (statut) {
case "draft": return "Brouillon";
case "published": return "Publié";
default: {
// Si un jour on ajoute "archived" à l'union,
// cette ligne cesse de compiler. C'est voulu.
const jamais: never = statut;
throw new Error(`Statut non géré : ${jamais}`);
}
}
}Ce motif est l'équivalent d'un match PHP sans branche par défaut, mais vérifié à la compilation plutôt qu'à l'exécution. Sur une application qui grandit, c'est ce qui vous évite d'oublier une branche quelque part dans le code.
Fonctions
Familier, avec deux subtilités
// Déclaration
function total(prix: number, tva = 0.2): number {
return prix * (1 + tva);
}
// Expression fléchée — la forme dominante en React
const total = (prix: number, tva = 0.2): number => prix * (1 + tva);
// Paramètre optionnel : number | undefined
function saluer(nom: string, titre?: string): string {
return titre ? `${titre} ${nom}` : nom;
}
// Reste
function somme(...nombres: number[]): number {
return nombres.reduce((a, b) => a + b, 0);
}
// Objet de paramètres — plus lisible au-delà de trois arguments
type OptionsRecherche = {
terme: string;
page?: number;
parPage?: number;
};
function rechercher({ terme, page = 1, parPage = 20 }: OptionsRecherche) { … }
// Type de fonction, réutilisable
type Formateur = (valeur: number, devise: string) => string;
const enEuros: Formateur = (v, d) => `${v.toFixed(2)} ${d}`;
// v et d sont inférés depuis Formateur : pas besoin de les annoterAsynchrone
Une fonction async retourne toujours une Promise. Le type de retour s'écrit Promise<T>, jamais T. C'est la source d'erreur numéro un chez les développeurs PHP.
public function recuperer(int $id): ?Article
{
$response = $this->client->request(
'GET', "/api/articles/{$id}"
);
if ($response->getStatusCode() === 404) {
return null;
}
return $this->denormalize(
$response->toArray(),
Article::class
);
}async function recuperer(id: number): Promise<Article | null> {
const reponse = await fetch(`/api/articles/${id}`);
if (reponse.status === 404) {
return null;
}
if (!reponse.ok) {
throw new Error(`HTTP ${reponse.status}`);
}
// ⚠️ .json() renvoie any — promesse non vérifiée
return (await reponse.json()) as Article;
}Surcharges
Comme en PHP il n'y a pas de surcharge réelle, mais TypeScript permet de déclarer plusieurs signatures pour une même implémentation. À utiliser avec parcimonie : une union bien pensée est souvent préférable.
function parser(valeur: string): number;
function parser(valeur: string[]): number[];
function parser(valeur: string | string[]): number | number[] {
return Array.isArray(valeur) ? valeur.map(Number) : Number(valeur);
}
const a = parser("42"); // number
const b = parser(["1", "2"]); // number[]Génériques
Les templates de PHPDoc, mais vérifiés
Vous les connaissez déjà, sous une forme dégradée : @template T dans PHPDoc, Collection<Article> dans Doctrine, @return array<int, User> pour PHPStan. En PHP, seul l'analyseur statique les comprend. En TypeScript, ils font partie du langage.
Un générique est un paramètre de type : au lieu de fixer le type, vous le laissez au point d'appel.
// Sans générique — il faudrait une version par type
function premierArticle(items: Article[]): Article | undefined {
return items[0];
}
// Avec générique — une seule version pour tout
function premier<T>(items: T[]): T | undefined {
return items[0];
}
premier([1, 2, 3]); // number | undefined
premier(articles); // Article | undefined
premier(["a"]); // string | undefinedContraindre le paramètre de type
// T doit avoir un id numérique
function trierParId<T extends { id: number }>(items: T[]): T[] {
return [...items].sort((a, b) => a.id - b.id);
}
trierParId(articles); // ✅ Article[] en entrée ET en sortie
trierParId([{ nom: "x" }]); // ❌ pas de propriété id
// K doit être une clé de T — extrêmement utile
function extraire<T, K extends keyof T>(objet: T, cle: K): T[K] {
return objet[cle];
}
const titre = extraire(article, "title"); // string
const vues = extraire(article, "views"); // number | undefined
extraire(article, "inexistant"); // ❌ erreur de compilation
// Valeur par défaut d'un paramètre de type
type Reponse<T = unknown> = {
donnees: T;
meta: { total: number };
};Un cas réel : le client d'API typé
type ReponseApi<T> = {
data: T;
meta?: { page: number; total: number };
};
async function api<T>(chemin: string, init?: RequestInit): Promise<T> {
const reponse = await fetch(`/api${chemin}`, {
headers: { "Content-Type": "application/json" },
...init,
});
if (!reponse.ok) {
throw new Error(`HTTP ${reponse.status} sur ${chemin}`);
}
return reponse.json() as Promise<T>;
}
// Au point d'appel, le retour est parfaitement typé
const liste = await api<ReponseApi<Article[]>>("/articles");
liste.data[0].title; // string, autocomplété
const un = await api<ReponseApi<Article>>("/articles/12");
un.data.publishedAt; // string | nullapi<Article>() ne vérifie rien — c'est une affirmation. La vérification réelle des données d'API se fait avec un validateur de schéma, section 16.Types utilitaires
La boîte à outils fournie
TypeScript livre une bibliothèque de types qui transforment d'autres types. C'est le moment où le typage cesse d'être une contrainte pour devenir un gain de temps : au lieu de recopier une interface pour en faire une variante, vous la dérivez.
interface Article {
id: number;
title: string;
slug: string;
body: string;
publishedAt: string | null;
authorId: number;
}
// Tous les champs optionnels — parfait pour un PATCH
type ArticlePartiel = Partial<Article>;
// Tous les champs obligatoires
type ArticleComplet = Required<Article>;
// Tous en lecture seule
type ArticleFige = Readonly<Article>;
// Sélectionner
type ApercuArticle = Pick<Article, "id" | "title" | "slug">;
// Exclure — le formulaire de création
type NouvelArticle = Omit<Article, "id" | "publishedAt">;
// Dictionnaire à clés contraintes
type ParStatut = Record<"draft" | "published", Article[]>;
// Manipulation d'unions
type Statut = "draft" | "published" | "archived";
type StatutVisible = Exclude<Statut, "archived">; // "draft" | "published"
type StatutFinal = Extract<Statut, "archived">; // "archived"
// Retirer null et undefined
type Date2 = NonNullable<string | null | undefined>; // string
// Déduire depuis une fonction
type Resultat = ReturnType<typeof recuperer>; // Promise<Article | null>
type Args = Parameters<typeof recuperer>; // [id: number]
type Attendu = Awaited<ReturnType<typeof recuperer>>; // Article | null| Utilitaire | Effet | Cas d'usage typique |
|---|---|---|
Partial<T> | tout devient optionnel | corps d'une requête PATCH |
Required<T> | tout devient obligatoire | après validation |
Readonly<T> | tout devient immuable | état d'un store |
Pick<T, K> | garde certaines clés | DTO d'aperçu, props d'un composant |
Omit<T, K> | retire certaines clés | formulaire de création (sans id) |
Record<K, V> | dictionnaire | table de correspondance exhaustive |
Exclude<U, M> | retire des membres d'une union | restreindre un enum |
NonNullable<T> | retire null et undefined | après un garde |
ReturnType<F> | type de retour d'une fonction | typer un store Redux |
Awaited<P> | déballe une Promise | type résolu d'un appel async |
Les composer
// Le corps d'un PATCH : tout optionnel, sauf les champs système
type MiseAJourArticle = Partial<Omit<Article, "id" | "authorId">>;
// Un aperçu enrichi
type ArticleListe = Pick<Article, "id" | "title" | "slug"> & {
auteur: Pick<User, "id" | "name">;
nbCommentaires: number;
};
// Rendre certains champs optionnels et pas d'autres
type Optionnel<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type BrouillonArticle = Optionnel<Article, "slug" | "body">;
// title reste obligatoire, slug et body deviennent facultatifsClasses
Votre terrain le plus familier
Presque tout se transpose depuis PHP 8. La promotion de propriétés dans le constructeur, les modificateurs de visibilité, readonly, les classes abstraites, les getters — vous retrouvez vos réflexes. Attention toutefois : en pratique, le code TypeScript moderne utilise beaucoup moins les classes que le code PHP. Les fonctions et les types suffisent souvent.
abstract class Depot
{
public function __construct(
protected readonly Connection $db,
private string $table,
) {}
abstract public function trouver(int $id): ?object;
protected function requete(string $sql): array
{
return $this->db->fetchAll($sql);
}
}
final class DepotArticle extends Depot
{
public function trouver(int $id): ?Article
{
return $this->requete("…")[0] ?? null;
}
}abstract class Depot {
constructor(
protected readonly db: Connection,
private table: string,
) {}
abstract trouver(id: number): Promise<object | null>;
protected async requete(sql: string): Promise<unknown[]> {
return this.db.fetchAll(sql);
}
}
class DepotArticle extends Depot {
override async trouver(id: number): Promise<Article | null> {
const lignes = await this.requete("…");
return (lignes[0] as Article) ?? null;
}
}| PHP | TypeScript | Note |
|---|---|---|
public function __construct(private X $x) | constructor(private x: X) | promotion identique |
private / protected / public | identiques | vérifiés à la compilation uniquement |
private réel | #champ | champ privé natif JS, réel à l'exécution |
readonly | readonly | identique |
abstract | abstract | identique |
final | aucun équivalent | pas de mot-clé final |
static | static | identique |
implements | implements | optionnel — le typage reste structurel |
__get / __set | get / set | accesseurs par propriété, pas magiques |
self / static | this | sémantique différente, attention |
| Traits | mixins | conventions, pas de mot-clé |
$this est toujours lié à l'instance. En JavaScript, this dépend de comment la fonction est appelée. Passez une méthode en callback (onClick={obj.methode}) et this devient undefined. Le remède standard : déclarer la méthode comme propriété fléchée, methode = () => { … }, qui capture le this de l'instance.Le champ privé réel
class Compte {
private solde = 0; // privé pour TypeScript seulement
#secret = "abc"; // privé réellement, à l'exécution
verifier() {
// (compte as any).solde → accessible à l'exécution
// compte.#secret → SyntaxError depuis l'extérieur
}
}Souvenez-vous du principe fondateur : private est une annotation effacée à la compilation. Si l'encapsulation doit tenir à l'exécution — code de bibliothèque, données sensibles — utilisez #.
Enums & constantes
Trois approches, une recommandée
PHP 8.1 a introduit des enums excellents. TypeScript a un enum plus ancien et plus problématique, et deux alternatives qui lui sont préférées par la communauté. Voici comment choisir.
enum Statut {
Draft = "draft",
Published = "published",
}
// Problèmes :
// • génère du code JS à l'exécution (contredit l'effacement des types)
// • incompatible avec l'option isolatedModules dans certains cas
// • const enum est déconseillé et parfois interdit par les bundlerstype Statut = "draft" | "published" | "archived";
const article: { statut: Statut } = { statut: "draft" };
// article.statut = "brouillon" → erreur, avec suggestion des valeurs valides
// Autocomplétion, zéro code généré, exhaustivité vérifiée dans les switch.
// C'est l'équivalent le plus direct d'un enum PHP backed par string.export const STATUT = {
Draft: "draft",
Published: "published",
Archived: "archived",
} as const;
// Le type dérivé de l'objet
export type Statut = (typeof STATUT)[keyof typeof STATUT];
// → "draft" | "published" | "archived"
// On garde l'itération et les libellés, comme un enum PHP avec méthode
export const LIBELLES: Record<Statut, string> = {
draft: "Brouillon",
published: "Publié",
archived: "Archivé",
};
Object.values(STATUT).forEach((s) => console.log(LIBELLES[s]));Ce que vous perdez par rapport à un enum PHP 8.1 : les méthodes sur les cas. En TypeScript, la logique se met dans une fonction ou une table de correspondance à côté du type.
enum Statut: string
{
case Draft = 'draft';
case Published = 'published';
public function libelle(): string
{
return match ($this) {
self::Draft => 'Brouillon',
self::Published => 'Publié',
};
}
public function estVisible(): bool
{
return $this === self::Published;
}
}
$s = Statut::from('draft');
$s->libelle();type Statut = "draft" | "published";
const LIBELLES: Record<Statut, string> = {
draft: "Brouillon",
published: "Publié",
};
function libelle(s: Statut): string {
return LIBELLES[s];
}
function estVisible(s: Statut): boolean {
return s === "published";
}
// Le "from" avec validation
function versStatut(v: string): Statut {
if (v === "draft" || v === "published") return v;
throw new Error(`Statut inconnu : ${v}`);
}as const dès que vous avez besoin d'itérer sur les valeurs ou d'un point unique de définition. N'utilisez enum que si une bibliothèque vous l'impose.null & undefined
Deux absences là où PHP n'en a qu'une
C'est une des rares zones où TypeScript est plus complexe que PHP. Deux valeurs distinctes expriment l'absence, et la distinction est réelle.
| Valeur | Sens | D'où elle vient typiquement |
|---|---|---|
undefined | jamais assigné | propriété absente, paramètre omis, retour implicite, tableau[99] |
null | absence volontaire | valeur explicitement vidée, colonne SQL NULL, JSON.parse('null') |
interface Article {
publishedAt: string | null; // colonne nullable en base → null
views?: number; // champ facultatif → number | undefined
}
const a: Article = { publishedAt: null };
a.views; // undefined — la propriété n'existe pas
a.publishedAt; // null — la propriété existe et vaut nullnull aux données qui viennent de la base (une colonne nullable) et undefined à ce qui est facultatif côté code (un paramètre, une prop React). Si votre API PHP renvoie null, typez | null ; si le champ peut être absent du JSON, typez-le optionnel avec ?.Les opérateurs de sûreté
// Chaînage optionnel — identique à PHP 8
const nom = article?.author?.name; // $article?->author?->name
// Coalescence nulle — identique à PHP 7
const vues = article.views ?? 0; // $article->views ?? 0
// ⚠️ Différence importante avec ||
const p1 = valeur || 10; // remplace aussi 0, "" et false
const p2 = valeur ?? 10; // remplace SEULEMENT null et undefined
// Affectation par coalescence
options.parPage ??= 20; // $options['parPage'] ??= 20
// Appel optionnel
callback?.(resultat);
// Accès indexé optionnel
const premier = liste?.[0];L'opérateur ! : à manier avec précaution
// ! dit au compilateur : « fais-moi confiance, ce n'est pas null »
const element = document.getElementById("app")!;
element.innerHTML = "…";
// Si l'élément n'existe pas → plantage à l'exécution, sans avertissement.
// TypeScript s'est tu parce que vous le lui avez demandé.
// ✅ Préférable
const element = document.getElementById("app");
if (!element) {
throw new Error("Conteneur #app introuvable");
}
element.innerHTML = "…"; // narrowing : ici, element n'est pas null! comme un @ en PHP : chaque occurrence est une petite dette. Utilisez-le uniquement quand vous avez une garantie que le compilateur ne peut pas voir, et commentez pourquoi. Un projet sain en compte très peu.noUncheckedIndexedAccess
const articles: Article[] = [];
// Sans l'option : TypeScript prétend que c'est un Article
const premier = articles[0]; // Article (mensonge !)
premier.title; // plante à l'exécution
// Avec noUncheckedIndexedAccess : la vérité
const premier = articles[0]; // Article | undefined
premier.title; // ❌ erreur de compilation
// Vous êtes forcé de gérer le cas
if (premier) {
premier.title; // ✅
}
// ou
const titre = articles[0]?.title ?? "Aucun";C'est plus strict que PHP, où $tableau[0] émet seulement une notice. Activez-le : les accès hors bornes sont une source réelle de bugs en production, et le compilateur les attrape tous.
Manipulation de types
Là où TypeScript n'a plus d'équivalent PHP
Cette section décrit le « langage dans le langage » : des opérateurs qui calculent des types à partir d'autres types. Vous n'en aurez pas besoin les premières semaines, mais c'est ce qui explique la puissance des bibliothèques typées que vous allez utiliser.
keyof, typeof, accès indexé
interface Article {
id: number;
title: string;
publishedAt: string | null;
}
// keyof — l'union des clés
type CleArticle = keyof Article; // "id" | "title" | "publishedAt"
// Accès indexé — le type d'une propriété
type Titre = Article["title"]; // string
type Valeurs = Article[keyof Article]; // number | string | null
// typeof — récupérer le type d'une VALEUR existante
const config = {
apiUrl: "https://api.exemple.fr",
timeout: 5000,
debug: false,
};
type Config = typeof config;
// { apiUrl: string; timeout: number; debug: boolean }
// Combinaison très fréquente
type CleConfig = keyof typeof config; // "apiUrl" | "timeout" | "debug"Mapped types
// C'est ainsi que Partial<T> est écrit dans la bibliothèque standard
type MonPartial<T> = {
[K in keyof T]?: T[K];
};
// Tout mettre en lecture seule
type MonReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// Retirer les modificateurs avec le préfixe -
type Mutable<T> = {
-readonly [K in keyof T]-?: T[K];
};
// Cas réel : un formulaire dérivé d'une entité
type Formulaire<T> = {
[K in keyof T]: { valeur: T[K]; erreur: string | null; touche: boolean };
};
type FormulaireArticle = Formulaire<Article>;
// { id: { valeur: number; erreur: string | null; touche: boolean }, … }
// Renommer les clés
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type ArticleGetters = Getters<Article>;
// { getId: () => number; getTitle: () => string; … }Types conditionnels
type EstTableau<T> = T extends unknown[] ? true : false;
type A = EstTableau<string[]>; // true
type B = EstTableau<string>; // false
// infer : capturer un type au passage
type Element<T> = T extends (infer U)[] ? U : never;
type C = Element<Article[]>; // Article
// C'est ainsi qu'Awaited est écrit
type MonAwaited<T> = T extends Promise<infer U> ? U : T;Template literal types
type Methode = "GET" | "POST" | "DELETE";
type Ressource = "articles" | "users";
type Route = `/api/${Ressource}`;
// "/api/articles" | "/api/users"
type Endpoint = `${Methode} ${Route}`;
// "GET /api/articles" | "POST /api/articles" | … (6 combinaisons)
// Cas réel : des clés d'événements typées
type Evenement = `article:${"created" | "updated" | "deleted"}`;
function on(evt: Evenement, cb: () => void) { … }
on("article:created", () => {}); // ✅
on("article:publie", () => {}); // ❌ erreur, avec suggestionssatisfies : le mot-clé à connaître
type Couleurs = Record<string, [number, number, number]>;
// Avec une annotation : le type devient Couleurs, on perd les clés précises
const palette1: Couleurs = {
moss: [63, 107, 34],
rust: [166, 58, 32],
};
palette1.inexistante; // ✅ accepté — pas ce qu'on veut
// Avec satisfies : vérifié ET précis
const palette2 = {
moss: [63, 107, 34],
rust: [166, 58, 32],
} satisfies Couleurs;
palette2.moss; // [number, number, number]
palette2.inexistante; // ❌ erreur — les clés sont connuessatisfies répond à un vrai besoin quotidien : « vérifie que mon objet respecte cette forme, mais garde le type exact de ce que j'ai écrit ». Utilisez-le pour vos objets de configuration, vos palettes, vos tables de routes.Modules & organisation
Les namespaces PSR-4, autrement
Différence structurelle : en PHP, un fichier peut contenir une classe et l'autoloader la trouve par convention PSR-4. En JavaScript, tout est privé au fichier tant que ce n'est pas exporté, et rien n'est trouvé automatiquement — chaque import est explicite.
// src/Service/InvoiceGenerator.php
namespace App\Service;
use App\Entity\Invoice;
use Psr\Log\LoggerInterface;
class InvoiceGenerator
{
// …
}
// Ailleurs — l'autoloader s'occupe de tout
use App\Service\InvoiceGenerator;// src/services/invoiceGenerator.ts
import type { Invoice } from "@/types/invoice";
import { logger } from "@/lib/logger";
export class InvoiceGenerator { … }
export function genererFacture() { … }
export const TVA = 0.2;
export default InvoiceGenerator; // export par défaut
// Ailleurs — chemin explicite
import InvoiceGenerator, { TVA } from "@/services/invoiceGenerator";
import type { Invoice } from "@/types/invoice";import { a, b } from "./module"; // nommés
import defaut from "./module"; // par défaut
import defaut, { a } from "./module"; // les deux
import * as tout from "./module"; // espace de noms
import { a as alias } from "./module"; // renommage
import "./styles.css"; // effet de bord
// import type : supprimé à la compilation, à privilégier pour les types
import type { Article } from "@/types/article";
import { type Article, recuperer } from "@/api/articles";
// Ré-export — le "barrel file" src/types/index.ts
export type { Article } from "./article";
export type { User } from "./user";import type pour les imports purement typés. Cela garantit que la ligne disparaît à la compilation et évite des imports circulaires à l'exécution — un problème que vous connaissez peut-être déjà avec les modules JS.Une organisation qui tient
src/
types/ // interfaces partagées, miroir de vos entités API
article.ts
user.ts
api.ts // ReponseApi<T>, pagination, erreurs
index.ts // barrel
api/ // couche d'accès HTTP — le seul endroit qui fetch
client.ts
articles.ts
features/ // par domaine métier, pas par type technique
articles/
ArticleList.tsx
ArticleCard.tsx
useArticles.ts
articlesSlice.ts
components/ // composants réutilisables sans logique métier
hooks/
lib/ // utilitaires purs
store.tsL'organisation par feature plutôt que par type technique est la convention dominante côté React, et elle correspond assez bien à un découpage par bounded context si vous en avez l'habitude côté Symfony.
TypeScript & React
Typer les props, l'état et les événements
Le typage des composants est plus simple qu'il n'y paraît : dans 90 % des cas vous typez uniquement les props, et l'inférence fait le reste.
import type { ReactNode } from "react";
import type { Article } from "@/types/article";
type ArticleCardProps = {
article: Article;
compact?: boolean;
onSelect?: (id: number) => void;
children?: ReactNode;
};
export function ArticleCard({ article, compact = false, onSelect }: ArticleCardProps) {
return (
<article onClick={() => onSelect?.(article.id)}>
<h3>{article.title}</h3>
{!compact && <p>{article.body}</p>}
</article>
);
}React.FC<Props>. C'était la convention il y a quelques années ; elle est aujourd'hui déconseillée car elle impose un children implicite et gêne les génériques. Typez simplement le paramètre de la fonction.Les hooks
// Inférence suffisante
const [ouvert, setOuvert] = useState(false); // boolean
const [compteur, setCompteur] = useState(0); // number
// Annotation nécessaire : la valeur initiale ne dit rien
const [articles, setArticles] = useState<Article[]>([]);
const [selection, setSelection] = useState<Article | null>(null);
const [erreur, setErreur] = useState<string | null>(null);
// Refs
const inputRef = useRef<HTMLInputElement>(null); // élément DOM
const timerRef = useRef<number | undefined>(undefined); // valeur mutable
// Reducer avec union discriminée — le pattern le plus solide
type Etat = { articles: Article[]; chargement: boolean; erreur: string | null };
type Action =
| { type: "chargement" }
| { type: "succes"; payload: Article[] }
| { type: "erreur"; payload: string };
function reducer(etat: Etat, action: Action): Etat {
switch (action.type) {
case "chargement": return { ...etat, chargement: true, erreur: null };
case "succes": return { articles: action.payload, chargement: false, erreur: null };
case "erreur": return { ...etat, chargement: false, erreur: action.payload };
}
}
const [etat, dispatch] = useReducer(reducer, initial);
dispatch({ type: "succes", payload: articles }); // payload vérifiéLes événements
// Laissez inférer quand le handler est en ligne
<button onClick={(e) => console.log(e.currentTarget)}>OK</button>
// Handler extrait : il faut annoter
import type { ChangeEvent, FormEvent, MouseEvent, KeyboardEvent } from "react";
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
setValeur(e.target.value);
};
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
const onClick = (e: MouseEvent<HTMLButtonElement>) => { … };
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") { … }
};
// Select et textarea
const onSelect = (e: ChangeEvent<HTMLSelectElement>) => { … };Un hook maison typé
type EtatRequete<T> =
| { statut: "chargement" }
| { statut: "succes"; donnees: T }
| { statut: "erreur"; message: string };
export function useApi<T>(chemin: string): EtatRequete<T> {
const [etat, setEtat] = useState<EtatRequete<T>>({ statut: "chargement" });
useEffect(() => {
let annule = false;
fetch(chemin)
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<T>;
})
.then((donnees) => {
if (!annule) setEtat({ statut: "succes", donnees });
})
.catch((e: unknown) => {
if (!annule) {
setEtat({
statut: "erreur",
message: e instanceof Error ? e.message : "Erreur inconnue",
});
}
});
return () => { annule = true; };
}, [chemin]);
return etat;
}
// Utilisation : le composant ne peut pas oublier un état
const etat = useApi<Article[]>("/api/articles");
if (etat.statut === "chargement") return <Spinner />;
if (etat.statut === "erreur") return <Erreur message={etat.message} />;
return <Liste articles={etat.donnees} />;catch (e: unknown) : en TypeScript moderne, la valeur attrapée est unknown, pas Error. On peut lancer n'importe quoi en JavaScript. Il faut donc vérifier avec e instanceof Error avant de lire .message — c'est plus strict que le catch (\Throwable $e) de PHP.Redux Toolkit typé
Votre stack front, avec les types
RTK est conçu pour TypeScript : l'inférence est excellente si vous suivez trois conventions. Le point clé est d'exporter des hooks pré-typés une fois pour toutes, puis de ne plus jamais importer useSelector ou useDispatch directement.
import { configureStore } from "@reduxjs/toolkit";
import articlesReducer from "@/features/articles/articlesSlice";
export const store = configureStore({
reducer: {
articles: articlesReducer,
},
});
// Les deux types dérivés du store — jamais écrits à la main
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;import { useDispatch, useSelector, useStore } from "react-redux";
import type { RootState, AppDispatch, store } from "@/store";
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppStore = useStore.withTypes<typeof store>();import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { Article } from "@/types/article";
interface ArticlesState {
items: Article[];
chargement: boolean;
erreur: string | null;
}
const initialState: ArticlesState = {
items: [],
chargement: false,
erreur: null,
};
export const chargerArticles = createAsyncThunk<
Article[], // ce que le thunk retourne
{ page: number }, // son argument
{ rejectValue: string } // le type d'un rejet
>("articles/charger", async ({ page }, { rejectWithValue }) => {
const r = await fetch(`/api/articles?page=${page}`);
if (!r.ok) {
return rejectWithValue(`HTTP ${r.status}`);
}
const json = (await r.json()) as { data: Article[] };
return json.data;
});
const articlesSlice = createSlice({
name: "articles",
initialState,
reducers: {
// PayloadAction<T> donne le type de action.payload
ajouter(state, action: PayloadAction<Article>) {
state.items.push(action.payload); // Immer : mutation autorisée
},
vider(state) {
state.items = [];
},
},
extraReducers: (builder) => {
builder
.addCase(chargerArticles.pending, (state) => {
state.chargement = true;
state.erreur = null;
})
.addCase(chargerArticles.fulfilled, (state, action) => {
state.chargement = false;
state.items = action.payload; // Article[], inféré
})
.addCase(chargerArticles.rejected, (state, action) => {
state.chargement = false;
state.erreur = action.payload ?? "Erreur inconnue";
});
},
});
export const { ajouter, vider } = articlesSlice.actions;
export default articlesSlice.reducer;
// Sélecteurs typés — l'état est connu
export const selectArticles = (s: RootState) => s.articles.items;
export const selectChargement = (s: RootState) => s.articles.chargement;import { useAppDispatch, useAppSelector } from "@/hooks";
import { chargerArticles, selectArticles } from "./articlesSlice";
export function ArticleList() {
const dispatch = useAppDispatch();
const articles = useAppSelector(selectArticles); // Article[]
useEffect(() => {
dispatch(chargerArticles({ page: 1 })); // argument vérifié
}, [dispatch]);
return <ul>{articles.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
}initialState avec une interface explicite, 2) utiliser PayloadAction<T> dans chaque reducer, 3) n'importer que useAppSelector et useAppDispatch. Avec ça, l'autocomplétion fonctionne sur tout l'état de l'application.Consommer une API PHP
Le point de jonction entre vos deux mondes
Vous allez typer côté front des données produites par Symfony ou Laravel. Trois niveaux de rigueur, du plus rapide au plus sûr. Le troisième est celui que je recommande dès qu'un projet dure.
Niveau 1 — l'assertion (rapide, non vérifiée)
const reponse = await fetch("/api/articles");
const articles = (await reponse.json()) as Article[];
// ⚠️ Aucune vérification. Si l'API renvoie autre chose
// — champ renommé, null inattendu, erreur 500 en HTML —
// le plantage arrive plus loin, au moment de l'affichage,
// avec un message incompréhensible.Niveau 2 — le garde manuel
function estArticle(v: unknown): v is Article {
return (
typeof v === "object" && v !== null &&
"id" in v && typeof v.id === "number" &&
"title" in v && typeof v.title === "string"
);
}
const brut: unknown = await reponse.json();
if (!Array.isArray(brut) || !brut.every(estArticle)) {
throw new Error("Réponse d'API inattendue sur /api/articles");
}
const articles: Article[] = brut;Correct, mais insoutenable : il faut réécrire le garde à chaque changement de l'interface, et rien ne garantit que les deux restent synchronisés.
Niveau 3 — le schéma (recommandé)
import { z } from "zod";
// Une seule source de vérité
export const ArticleSchema = z.object({
id: z.number().int(),
title: z.string().min(1),
slug: z.string(),
body: z.string(),
published_at: z.string().datetime().nullable(),
author: z.object({
id: z.number().int(),
name: z.string(),
}),
tags: z.array(z.string()).default([]),
});
// Le type TypeScript EST le schéma
export type Article = z.infer<typeof ArticleSchema>;
export const ReponseArticles = z.object({
data: z.array(ArticleSchema),
meta: z.object({
current_page: z.number(),
total: z.number(),
}),
});export async function recupererArticles(page = 1) {
const r = await fetch(`/api/articles?page=${page}`, {
headers: { Accept: "application/json" },
});
if (!r.ok) {
throw new Error(`HTTP ${r.status} sur /api/articles`);
}
const brut: unknown = await r.json();
// Ici la vérification est RÉELLE, à l'exécution
const parsed = ReponseArticles.safeParse(brut);
if (!parsed.success) {
console.error("Contrat d'API rompu :", parsed.error.format());
throw new Error("Réponse inattendue de l'API");
}
return parsed.data; // typé ET vérifié
}published_at en publishedAt dans une Resource Laravel ou un groupe de sérialisation Symfony, Zod le détecte immédiatement, avec un message précis, au lieu d'un undefined silencieux qui s'affiche trois écrans plus loin.Correspondance des types PHP → TypeScript
| PHP / JSON | TypeScript | Attention |
|---|---|---|
int | number | |
float | number | précision flottante : pas pour l'argent |
string | string | |
bool | boolean | attention aux 0/1 renvoyés par MySQL |
?string | string | null | |
array<T> (liste) | T[] | |
array<string, T> | Record<string, T> | un tableau PHP vide sérialise en [], pas {} |
DateTimeImmutable | string | toujours ISO 8601 ; convertir explicitement en Date |
enum: string | union de littéraux | |
Money / DECIMAL | string ou centimes en number | jamais un float |
| objet non chargé (relation) | propriété optionnelle | whenLoaded() côté Laravel |
[] contre {} vous mordra au moins une fois : en PHP, un tableau associatif vide se sérialise en [] et non en {}. Si vous typez Record<string, T>, l'API peut renvoyer un tableau. Côté Laravel, forcez avec (object) $data ; côté Symfony, avec \ArrayObject.Les 14 pièges
Ce qui surprend quand on vient de PHP
- 01Croire que les types protègent à l'exécution. Ils disparaissent à la compilation. Toute donnée externe doit être validée — voir la section 16.
- 02Sur-annoter.
const nom: string = "x"est du bruit. Annotez les frontières, laissez inférer le reste. - 03Utiliser
anypour faire taire une erreur. Il éteint le compilateur en cascade. Préférezunknownpuis un narrowing. - 04Abuser de
!. Chaque assertion de non-nullité est un pari. Utilisez un garde explicite. - 05Confondre
||et??.compteur || 10remplace aussi le zéro. Utilisez??pour ne viser quenulletundefined. - 06Croire que
numberest un entier. C'est un flottant. Les montants se stockent en centimes ou en chaînes. - 07Oublier
Promise<T>. Une fonctionasyncretourne toujours une promesse. Oublier unawaitdonne un objetPromiselà où vous attendiez une valeur. - 08Ignorer
strict: true. Sans lui, TypeScript ne rend qu'une fraction du service, et l'activer plus tard coûte très cher. - 09Utiliser
enumpar réflexe PHP. Préférez l'union de littéraux ou l'objetas const. - 10Comparer avec
==. Toujours===. Le==de JavaScript a des règles de coercition pires que celles de PHP. - 11Croire que
thisse comporte comme$this. Il dépend de l'appel. Utilisez des fonctions fléchées pour les callbacks. - 12Muter un objet d'état React.
etat.items.push(x)ne redéclenche pas de rendu. Créez un nouvel objet — sauf dans un reducer RTK, où Immer autorise la mutation. - 13Recopier une interface pour en faire une variante. Dérivez-la avec
Pick,Omit,Partial. Une seule source par entité. - 14Ne pas lire le message d'erreur en entier. Les erreurs TypeScript sont longues mais précises : la vraie cause est presque toujours dans la dernière ligne, pas la première.