TypeScript für PHP-Entwickler: Das mentale Modell, mit dem es klick macht
Die meisten PHP-Entwickler, mit denen ich an Next.js-Projekten gearbeitet habe, haben etwa eine Woche mit TypeScript gerungen, dann hat es Klick gemacht, und sie waren schneller als die React-Leute, die nie eine typisierte Sprache benutzt hatten. Die Woche des Ringens lässt sich vermeiden. Fast jede Anleitung zu TypeScript für PHP-Entwickler startet bei JavaScript und packt Typen obendrauf, was rückwärts ist für jemanden, der seit Jahren declare(strict_types=1) schreibt. Du hast das Typsystem bereits im Kopf. Was du brauchst, ist die Übersetzungstabelle.
Dieser Beitrag ist diese Tabelle. Er geht die PHP-Konzepte durch, die du täglich benutzt, zeigt das TypeScript-Äquivalent und weist auf die Handvoll Stellen hin, an denen dasselbe Wort etwas anderes bedeutet. Der letzte Abschnitt behandelt, was TypeScript ausdrücken kann und PHP nicht, denn genau da hört die Sprache auf, sich wie ein Port anzufühlen, und fängt an, sich wie ein Werkzeug anzufühlen.
Wo die Sprachen stehen
Beide Sprachen haben Typen zu einer dynamischen Basis hinzugefügt. PHP begann untypisiert, bekam Scalar Type Hints in PHP 7, Union Types in 8.0 und Enums in 8.1. TypeScript ist eine Obermenge von JavaScript, die eine statische Typschicht hinzufügt. Der Unterschied, wie sie Typen durchsetzen, ist das Erste, das man verinnerlichen muss.
PHP prüft Typen zur Laufzeit. Wenn eine Funktion mit einem string-Parameter deklariert ist und du unter strict_types=1 eine Ganzzahl übergibst, wirft PHP beim Aufruf einen TypeError. Die Prüfung kostet zur Laufzeit ein bisschen, und sie passiert sogar in Codepfaden, die du nie getestet hast.
TypeScript prüft Typen zur Kompilierzeit und wirft sie danach weg. Das erzeugte JavaScript enthält überhaupt keine Typinformation. Wenn du einen Parameter als string deklarierst und etwas zur Laufzeit eine Zahl übergibt (sagen wir, ein JSON-Payload von einer API), hält nichts das auf. Der Compiler hat deiner Annotation vertraut, und die Annotation war falsch. Deshalb brauchen TypeScript-Projekte Validierung an der Grenze (Zod, Valibot oder handgeschriebene Guards), auf eine Art, wie PHP-Projekte mit strict_types das nicht brauchen. Wenn du nur eine Idee aus diesem Beitrag in deine erste Next.js-Komponente mitnimmst, nimm diese: Der Typ auf einem fetch-Ergebnis ist ein Versprechen, das du dem Compiler gegeben hast, keine Tatsache, die der Compiler überprüft hat.
Interfaces: gleiches Schlüsselwort, andere Regel
Ein PHP-Interface ist ein Vertrag, den eine Klasse explizit implementieren muss. Zwei Klassen mit identischen Methoden sind nicht austauschbar, es sei denn, beide deklarieren implements auf demselben Interface. Das ist nominale Typisierung: Namen zählen.
TypeScript verwendet strukturelle Typisierung. Ein Wert erfüllt ein Interface, wenn er die richtige Form hat, egal ob jemals irgendwo deklariert wurde, dass er das Interface implementiert.
interface HasEmail {
email: string;
}
const customer = { id: 42, email: 'anna@example.com', plan: 'pro' };
function notify(target: HasEmail) { /* ... */ }
notify(customer); // funktioniert, customer hat eine email: string
Nichts im obigen Code verbindet customer mit HasEmail. Der Compiler hat sich die Form angesehen und sie akzeptiert. Die Eigenschaften id und plan sind zusätzlich und spielen beim Übergeben einer Variable keine Rolle.
Das lässt PHP-Entwickler auf zwei Arten stolpern. Erstens schreibst du aus Gewohnheit class Foo implements Bar und fragst dich, warum es dem Compiler egal ist, wenn du es weglässt. Es ist egal, weil er Formen prüft, und implements in TypeScript ist nur ein früher Fehler, falls die Form abweicht. Zweitens bedeutet strukturelle Typisierung, dass zwei unverwandte Typen versehentlich kompatibel sein können. Eine UserId und eine OrderId, die beide number sind, sind für den Compiler derselbe Typ. PHP-Entwickler, die Value Objects genau aus diesem Grund verwenden, werden Branded Types wollen, ein kleiner Trick mit einer Intersection und einer Phantom-Eigenschaft.
Nullable Types passen exakt
Das ist eine Erleichterung. PHPs ?string oder string|null ist TypeScripts string | null. Beide Sprachen zwingen dich, den Null-Fall zu behandeln, bevor du den Wert benutzt, PHP über einen Laufzeitfehler und TypeScript über einen Kompilierfehler unter strictNullChecks.
function displayName(user: { name: string | null }): string {
return user.name ?? 'Anonymous';
}
Der ??-Operator ist dasselbe Null Coalescing, das du aus PHP 7 kennst. Optional Chaining user?.profile?.city ist PHP 8s Nullsafe-Operator $user?->profile?->city mit einem Zeichen weniger. Sogar die Semantik rund um undefined liegt nah an der Art, wie PHP einen ungesetzten Array-Key behandelt: Es gibt einen eigenen "nicht da"-Zustand, getrennt von "da, aber null", und TypeScript macht ihn als Typ undefined sichtbar. Eine optionale Eigenschaft, geschrieben als name?: string, hat den Typ string | undefined, nicht string | null. Die meisten PHP-Entwickler vermischen die beiden ein paar Tage lang und hören dann auf.
Union Types, aber überall
PHP 8 Union Types lassen dich int|string für einen Parameter schreiben. TypeScript-Unions gehen weiter, weil die Mitglieder auch literale Werte sein können, nicht nur Typen.
type Plan = 'free' | 'pro' | 'enterprise';
Eine Plan-Variable kann genau diese drei Strings enthalten und nichts sonst. 'premium' zuzuweisen ist ein Kompilierfehler. In PHP würdest du zu einem Enum oder einer Klasse mit Konstanten und einer Validierungsmethode greifen. In TypeScript ist die literale Union das idiomatische Werkzeug für die kleinen Fälle, und sie kostet zur Laufzeit nichts, weil das erzeugte JavaScript nur ein String ist.
Enums: benutze sie seltener, als du erwartest
PHP 8.1s Backed Enums sind exzellent, und du benutzt sie wahrscheinlich oft. TypeScript hat ein enum-Schlüsselwort, und der Rat der Community lautet meist, es zu vermeiden. Reguläre TypeScript-Enums kompilieren zu einem Laufzeit-Objekt mit einem etwas seltsamen Reverse-Mapping-Verhalten für numerische Werte, und const enum wird vom Compiler auf eine Weise inline gesetzt, die unter manchen Build-Tools bricht (einschließlich des Transpile-only-Modus, den Next.js über SWC verwendet).
Der übliche Ersatz ist die literale Union von oben, oder ein as const-Objekt, wenn du die Werte iterieren musst:
const Plan = {
Free: 'free',
Pro: 'pro',
Enterprise: 'enterprise',
} as const;
type Plan = (typeof Plan)[keyof Plan]; // 'free' | 'pro' | 'enterprise'
Diese zweite Zeile wirkt zunächst befremdlich. Lies sie als "der Typ der Werte innerhalb des Plan-Objekts". Du bekommst Object.values(Plan) zum Iterieren und den Union-Typ zum Prüfen, ohne das Enum-Schlüsselwort. Falls dir tryFrom() fehlt, erledigt eine zweizeilige Guard-Funktion denselben Job.
Generics: die kennst du schon aus PHPStan
Wenn du eine Collection mit @template T annotiert oder @return array<int, User> in PHPStan- oder Psalm-Docblocks geschrieben hast, hast du Generics geschrieben. TypeScript macht sie zum Teil der Sprache statt zu einem Kommentar.
function first<T>(items: T[]): T | undefined {
return items[0];
}
const u = first(users); // u ist User | undefined
Inferenz übernimmt die meiste Arbeit. Du musst selten first<User>(users) schreiben, weil der Compiler den Argumenttyp liest. Constraints benutzen extends, wo PHPStan @template T of Foo benutzt. Defaults gibt es auch: <T = string>.
Die größere Verschiebung ist, dass TypeScripts Generics von dem Compiler geprüft werden, der mit der Sprache mitgeliefert wird, es gibt also kein Äquivalent zu "PHPStan Level 9 in CI, aber niemand führt es lokal aus". Die Typen sind der Build.
Utility Types ersetzen Boilerplate mit abstrakten Klassen
In PHP schreibst du, wenn du eine Variante einer Klasse brauchst (sagen wir, dieselben Felder, aber alles optional für einen PATCH-Request), eine zweite Klasse oder ein DTO. TypeScript hat eingebaute Typ-Operatoren, die einen Typ aus einem anderen ableiten.
interface User {
id: number;
email: string;
name: string;
createdAt: Date;
}
type UpdateUserInput = Partial<Omit<User, 'id' | 'createdAt'>>;
type UserSummary = Pick<User, 'id' | 'name'>;
Partial, Required, Pick, Omit, Record und Readonly decken das meiste ab, was du als separate DTO-Klassen geschrieben hättest. Es sind reine Typ-Ebene-Konstrukte. Es wird kein Code generiert, nichts läuft, und wenn sich der Basistyp User ändert, folgen die abgeleiteten Typen automatisch. Zur Frage type versus interface: für Objektformen sind sie fast austauschbar. Benutze interface für Dinge, die anderer Code erweitern wird, und type für Unions und abgeleitete Typen.
Strict Mode ist strict_types, plus mehr
declare(strict_types=1) verhindert, dass PHP Scalar Types an Aufrufgrenzen umwandelt. TypeScripts "strict": true in tsconfig.json ist ein Bündel von Flags, und zwei davon zählen weit mehr als der Rest. strictNullChecks ist das, was string "definitiv ein String" bedeuten lässt, statt "ein String, oder null, oder undefined, wer weiß". noImplicitAny verhindert, dass ein untypisierter Parameter still zu any wird, TypeScripts Fluchttür, die den Checker für diesen Wert abschaltet.
Schalte Strict Mode in jedem neuen Projekt ein. In einer bestehenden Codebasis braucht die Migration Planung, und wir haben separat über das schrittweise Aktivieren von Strict Mode geschrieben. Die Kurzfassung für einen PHP-Entwickler: any ist das Äquivalent dazu, jeden Type Hint aus einer Funktion zu entfernen, und eine Codebasis mit verstreutem any hat die Typsicherheit von PHP 5.
Was TypeScript kann und PHP nicht
Alles bisher war Übersetzung. Diese drei Features haben kein PHP-Gegenstück und sind der Grund, warum erfahrene PHP-Entwickler TypeScript am Ende mögen statt nur zu ertragen.
Typen zum Nulltarif
Weil Typen zur Kompilierzeit gelöscht werden, kannst du deine Domäne so präzise modellieren, wie du willst, ohne Laufzeitkosten. Ein PHP-Value-Object kostet eine Allokation und einen Konstruktoraufruf. Ein TypeScript-Branded-Type oder eine tief verschachtelte Union kostet nach der Kompilierung nichts. Das ändert, wie viel Typisierung du bereit bist zu betreiben. Jede API-Antwortform in PHP zu modellieren bedeutet, Klassen und Hydratoren zu schreiben. In TypeScript bedeutet es, die Form einmal zu schreiben und die Inferenz den Rest tragen zu lassen.
Discriminated Unions und erschöpfendes Matching
Eine Discriminated Union ist eine Union von Objekttypen, die eine literale Eigenschaft teilen. Der Compiler grenzt den Typ ein, wenn du diese Eigenschaft prüfst.
type PaymentResult =
| { status: 'success'; transactionId: string }
| { status: 'declined'; reason: string }
| { status: 'pending'; retryAfter: number };
function describe(result: PaymentResult): string {
switch (result.status) {
case 'success':
return `Paid (${result.transactionId})`;
case 'declined':
return `Declined: ${result.reason}`;
case 'pending':
return `Retry in ${result.retryAfter}s`;
}
}
Innerhalb jedes case hat result nur die Eigenschaften dieses Zweigs. Greifst du im Success-Zweig auf result.reason zu, bekommst du einen Kompilierfehler. Fügst du der Union einen vierten Status hinzu, schlägt jeder switch, der ihn nicht behandelt, beim Kompilieren fehl, sofern die Funktion einen deklarierten Rückgabetyp hat. PHP-8-Enums mit match bringen dich für das Enum selbst ein Stück weit, aber sie können keine unterschiedlichen Payloads pro Fall anhängen. In PHP würdest du eine Klassenhierarchie und instanceof-Prüfungen schreiben, und nichts sagt dir, wenn du eine Unterklasse übersehen hast.
Dieses Muster ist überall im React-Code: Komponenten-Props, die sich je nach Variante unterscheiden, Formularzustände, Request-Lebenszyklen. Hast du es einmal gesehen, fangen die Hälfte der if ($x instanceof Y)-Ketten in deiner PHP-Codebasis an, wie Discriminated Unions auszusehen, die die Sprache nicht ausdrücken konnte.
Conditional und Mapped Types
Typen können sich auf andere Typen verzweigen. T extends string ? A : B ist ein Typ-Ebene-Ternary. Mapped Types iterieren über die Keys eines anderen Typs und transformieren jeden davon. Zusammen erlauben sie Library-Autoren, Dinge zu schreiben wie "der Rückgabetyp dieser Funktion ist das, was der Callback zurückgibt, eingepackt in ein Promise, mit allen nullable Feldern als required". Du wirst diese eher lesen als schreiben, meist in den Signaturen von Libraries wie Prisma, Drizzle und tRPC.
Eine erste Next.js-Komponente, übersetzt
Hier ist, wie die erste Server-Komponente eines PHP-Entwicklers nach einer Woche mit diesem mentalen Modell aussieht.
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
plan: z.enum(['free', 'pro', 'enterprise']),
});
type User = z.infer<typeof UserSchema>;
async function getUser(id: number): Promise<User> {
const res = await fetch(`${process.env.API_URL}/users/${id}`);
return UserSchema.parse(await res.json());
}
export default async function UserCard({ id }: { id: number }) {
const user = await getUser(id);
return (
<div>
<h2>{user.name}</h2>
<p>{user.plan === 'enterprise' ? 'Priority support' : 'Standard support'}</p>
</div>
);
}
Das Schema tut zur Laufzeit, was strict_types an der Grenze in PHP getan hätte, und z.infer leitet den statischen Typ davon ab, sodass die beiden nicht auseinanderdriften können. Das Props-Objekt ist inline typisiert. Der Vergleich von plan gegen ein Literal wird geprüft: verschreib dich mit 'enterprize' und der Build schlägt fehl. Keiner der Typen überlebt in das Browser-Bundle.
Wann du dir Hilfe holen solltest
Teams, die ein PHP-Backend in Richtung Next.js-Frontend bewegen, oder die einen TypeScript-Service neben einem Symfony-Monolithen einführen, bekommen die Syntax meist in einer Woche richtig und die Architektur sechs Monate lang falsch. Die häufigen Fehler sind any, das sich durch die Codebasis verbreitet, weil Strict Mode am Anfang aus war, API-Typen, die handgeschrieben an zwei Stellen stehen und leise auseinanderdriften, und Enums, die eins zu eins aus PHP portiert wurden, in eine Form, mit der der Bundler nicht klarkommt. Ein kurzes Code-Review nach den ersten paar tausend Zeilen fängt das ab, während es noch günstig zu beheben ist. Für größere Umzüge entscheidet Arbeit an der Tech-Stack-Strategie im Vorfeld, wo die Typgrenze zwischen PHP und TypeScript liegt, was die Entscheidung ist, die alles andere bestimmt.
Wenn du ein PHP-Entwickler mitten in diesem Übergang bist und etwas in deiner Codebasis nicht zu dem Modell oben passt, schreib an hello@wolf-tech.io oder schau dir an, was wir bei wolf-tech.io machen. Ein zweiter Blick auf eine tsconfig und eine Handvoll Typen ist ein kleiner Job, der einen großen erspart.

