Node 20+ · tsc

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.

01

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 tsc produit réellement
// 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;
}
Conséquence pratiqueToute donnée venant de l'extérieur — 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.

PHP · nominal
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();
}
TypeScript · structurel
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.

Annoter, oui, mais où
// ❌ 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;
Règle de conduiteAnnotez les entrées et sorties (paramètres, types de retour publics, réponses d'API, état initial vide). Laissez tout le reste être inféré. C'est l'inverse du réflexe PHP où l'on type chaque propriété.
02

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

tsconfig.json
{
  "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"]
}
OptionCe qu'elle changeAnalogie PHP
strictactive sept vérifications d'un coup, dont strictNullChecksdeclare(strict_types=1), en beaucoup plus large
strictNullChecksnull n'est plus assignable partouttypes nullables explicites ?string
noImplicitAnyinterdit les paramètres non typésinterdire mixed implicite
noUncheckedIndexedAccesstab[0] devient T | undefinedaucun équivalent — c'est plus strict que PHP
exactOptionalPropertyTypesdistingue « absent » et « défini à undefined »aucun équivalent
noEmitvérifie sans compiler (Vite compile)PHPStan sans build
À retenirActivez 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.
03

Types de base

Ce qui existe en PHP, et les cinq qui n'existent pas

TypeScriptPHPNote
stringstringidentique
numberint + floatun seul type numérique : pas d'entier distinct
bigintint (64 bits)pour les grands entiers
booleanboolidentique
string[]array<string>liste homogène
[string, number]aucun équivalenttuple : longueur et types fixes
Record<string, T>array<string, T>objet indexé par clé
nullnulll'absence choisie
undefinedaucun équivalentl'absence non initialisée — voir section 11
voidvoidla fonction ne retourne rien d'utile
neverneverne retourne jamais (throw, boucle infinie)
unknownmixedà narrower avant usage
anypas de typage du toutdésactive le compilateur — à éviter
'draft' | 'published'enum, approximativementtype littéral : la valeur est le type
Le piège numberIl n'y a pas d'entier en TypeScript. number est un flottant IEEE 754, comme float en PHP. Conséquence directe : ne stockez jamais un montant en euros dans un number0.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 éteint le compilateur, unknown le force à travailler
// ❌ 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

Une notion sans équivalent PHP
// 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"
04

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.

Les deux formes
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 objetouioui
Union (A | B)nonoui
Tuplenonoui
Type de fonctionpossible mais lourdoui
Extensionextendsintersection &
Fusion automatique de deux déclarationsouinon
Types calculés (mapped, conditional)nonoui

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.

PHP · héritage
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
{
    // …
}
TypeScript · composition
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

L'équivalent d'un array associatif PHP
// 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>;
À retenirRecord<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.
05

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.

Le narrowing en action
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

Votre boîte à outils
// 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.

Modéliser un état de chargement
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}`;
  }
}
Pourquoi c'est meilleur qu'un objet à champs optionnelsL'alternative naïve — { 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é

Le garde-fou never
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.

06

Fonctions

Familier, avec deux subtilités

Les formes courantes
// 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 annoter
À retenirDernière ligne, point important : quand le type de la fonction est déjà connu par le contexte, n'annotez pas les paramètres. C'est l'inférence contextuelle, et c'est ce qui rend le code React typé beaucoup plus léger qu'on ne le craint.

Asynchrone

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.

PHP · synchrone
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
    );
}
TypeScript · asynchrone
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.

Signatures multiples
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[]
07

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.

Du concret vers le générique
// 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 | undefined

Contraindre le paramètre de type

extends dans un générique = « au moins cette forme »
// 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é

Le générique rend le retour précis au point d'appel
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 | null
À retenirUn générique n'existe pas plus à l'exécution que le reste. api<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.
08

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.

Sur une seule interface source
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
UtilitaireEffetCas d'usage typique
Partial<T>tout devient optionnelcorps d'une requête PATCH
Required<T>tout devient obligatoireaprès validation
Readonly<T>tout devient immuableétat d'un store
Pick<T, K>garde certaines clésDTO d'aperçu, props d'un composant
Omit<T, K>retire certaines clésformulaire de création (sans id)
Record<K, V>dictionnairetable de correspondance exhaustive
Exclude<U, M>retire des membres d'une unionrestreindre un enum
NonNullable<T>retire null et undefinedaprès un garde
ReturnType<F>type de retour d'une fonctiontyper un store Redux
Awaited<P>déballe une Promisetype résolu d'un appel async

Les composer

C'est là que ça devient rentable
// 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 facultatifs
À retenirLe réflexe à prendre : une seule interface source par entité, et toutes les variantes dérivées. Si vous recopiez une interface pour en changer deux champs, vous venez de créer une dette — le jour où l'entité change, une des deux copies sera oubliée.
09

Classes

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.

PHP 8
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;
    }
}
TypeScript
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;
  }
}
PHPTypeScriptNote
public function __construct(private X $x)constructor(private x: X)promotion identique
private / protected / publicidentiquesvérifiés à la compilation uniquement
private réel#champchamp privé natif JS, réel à l'exécution
readonlyreadonlyidentique
abstractabstractidentique
finalaucun équivalentpas de mot-clé final
staticstaticidentique
implementsimplementsoptionnel — le typage reste structurel
__get / __setget / setaccesseurs par propriété, pas magiques
self / staticthissémantique différente, attention
Traitsmixinsconventions, pas de mot-clé
Le piège thisEn PHP, $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

private vs #
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 #.

10

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.

Approche 1 — enum natif (à éviter par défaut)
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 bundlers
Approche 2 — union de littéraux (recommandée)
type 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.
Approche 3 — objet const (quand il faut itérer)
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.

PHP 8.1
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();
TypeScript
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}`);
}
RecommandationUtilisez l'union de littéraux par défaut. Passez à l'objet 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.
11

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.

ValeurSensD'où elle vient typiquement
undefinedjamais assignépropriété absente, paramètre omis, retour implicite, tableau[99]
nullabsence volontairevaleur explicitement vidée, colonne SQL NULL, JSON.parse('null')
En pratique
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 null
Convention simpleRéservez null 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é

Vous en connaissez déjà la moitié
// 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

L'assertion de non-nullité
// ! 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
À retenirTraitez ! 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

L'option qui surprend les développeurs PHP
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.

12

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é

Les trois briques de base
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

Transformer chaque propriété
// 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

Un if ternaire au niveau des types
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

Composer des chaînes au niveau du type
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 suggestions

satisfies : le mot-clé à connaître

Vérifier sans élargir
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 connues
À retenirsatisfies 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.
13

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.

PHP · PSR-4
// 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;
TypeScript · ES modules
// 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";
Les formes d'import
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";
À retenirPrenez l'habitude d'écrire 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/ — structure éprouvée pour une app React
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.ts

L'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.

14

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.

Un composant typé
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>
  );
}
À retenirN'utilisez pas 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

Quand annoter, quand se taire
// 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

Les types que vous chercherez
// 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é

Générique + union discriminée
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} />;
À retenirRemarquez le 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.
15

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.

src/store.ts
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;
src/hooks.ts — à importer partout à la place des originaux
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>();
src/features/articles/articlesSlice.ts
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;
Dans un composant
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>;
}
À retenirLes trois règles : 1) typer 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.
16

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)

Ce que fait tout le monde au début
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

Un predicate écrit à la main
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é)

npm i zod — le type est dérivé du schéma
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(),
  }),
});
Le client, avec validation réelle
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é
}
Pourquoi ça compte pour vousVous contrôlez le back et le front. Le jour où vous renommez 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 / JSONTypeScriptAttention
intnumber
floatnumberprécision flottante : pas pour l'argent
stringstring
boolbooleanattention aux 0/1 renvoyés par MySQL
?stringstring | null
array<T> (liste)T[]
array<string, T>Record<string, T>un tableau PHP vide sérialise en [], pas {}
DateTimeImmutablestringtoujours ISO 8601 ; convertir explicitement en Date
enum: stringunion de littéraux
Money / DECIMALstring ou centimes en numberjamais un float
objet non chargé (relation)propriété optionnellewhenLoaded() côté Laravel
À retenirLe piège [] 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.
17

Les 14 pièges

Ce qui surprend quand on vient de PHP

  1. 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.
  2. 02Sur-annoter. const nom: string = "x" est du bruit. Annotez les frontières, laissez inférer le reste.
  3. 03Utiliser any pour faire taire une erreur. Il éteint le compilateur en cascade. Préférez unknown puis un narrowing.
  4. 04Abuser de !. Chaque assertion de non-nullité est un pari. Utilisez un garde explicite.
  5. 05Confondre || et ??. compteur || 10 remplace aussi le zéro. Utilisez ?? pour ne viser que null et undefined.
  6. 06Croire que number est un entier. C'est un flottant. Les montants se stockent en centimes ou en chaînes.
  7. 07Oublier Promise<T>. Une fonction async retourne toujours une promesse. Oublier un await donne un objet Promise là où vous attendiez une valeur.
  8. 08Ignorer strict: true. Sans lui, TypeScript ne rend qu'une fraction du service, et l'activer plus tard coûte très cher.
  9. 09Utiliser enum par réflexe PHP. Préférez l'union de littéraux ou l'objet as const.
  10. 10Comparer avec ==. Toujours ===. Le == de JavaScript a des règles de coercition pires que celles de PHP.
  11. 11Croire que this se comporte comme $this. Il dépend de l'appel. Utilisez des fonctions fléchées pour les callbacks.
  12. 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.
  13. 13Recopier une interface pour en faire une variante. Dérivez-la avec Pick, Omit, Partial. Une seule source par entité.
  14. 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.
Ce que votre expérience PHP vous donneLe typage strict, les interfaces, l'injection de dépendances, la séparation des couches, la validation des entrées — vous avez déjà tous ces réflexes. TypeScript vous demandera surtout d'apprendre ce que PHP n'a pas : les unions discriminées, le narrowing, les types dérivés. C'est trois ou quatre concepts, pas un langage entier.