TypeScript for PHP Developers: The Mental Model That Makes It Click
Most of the PHP developers I have worked with on Next.js projects struggled with TypeScript for about a week, then something clicked and they were faster than the React people who had never used a typed language. The week of struggle is avoidable. Almost every guide to TypeScript for PHP developers starts from JavaScript and adds types on top, which is backwards for someone who has been writing declare(strict_types=1) for years. You already have the type system in your head. What you need is the translation table.
This post is that table. It walks through the PHP concepts you use daily, shows the TypeScript equivalent, and points out the handful of places where the same word means a different thing. The last section covers what TypeScript can express that PHP cannot, because that is where the language stops feeling like a port and starts feeling like a tool.
Where the languages sit
Both languages added types to a dynamic base. PHP started untyped, grew scalar type hints in PHP 7, union types in 8.0 and enums in 8.1. TypeScript is a superset of JavaScript that adds a static type layer. The difference in how they enforce types is the first thing to internalize.
PHP checks types at runtime. If a function is declared with a string parameter and you pass an integer under strict_types=1, PHP throws a TypeError when the call executes. The check costs a little at runtime and it happens even in code paths you never tested.
TypeScript checks types at compile time and then throws the types away. The emitted JavaScript contains no type information at all. If you declare a parameter as string and something at runtime passes a number (say, a JSON payload from an API), nothing stops it. The compiler trusted your annotation, and the annotation was wrong. This is why TypeScript projects need validation at the boundary (Zod, Valibot or hand-written guards) in a way that PHP projects with strict_types do not. If you carry one idea from this post into your first Next.js component, carry this one: the type on a fetch result is a promise you made to the compiler, not a fact the compiler verified.
Interfaces: same keyword, different rule
A PHP interface is a contract that a class must explicitly implement. Two classes with identical methods are not interchangeable unless both declare implements on the same interface. This is nominal typing: names matter.
TypeScript uses structural typing. A value satisfies an interface if it has the right shape, whether or not anything ever declared that it implements the interface.
interface HasEmail {
email: string;
}
const customer = { id: 42, email: 'anna@example.com', plan: 'pro' };
function notify(target: HasEmail) { /* ... */ }
notify(customer); // fine, customer has an email: string
Nothing in the code above links customer to HasEmail. The compiler looked at the shape and accepted it. The id and plan properties are extra and do not matter when passing a variable.
This trips up PHP developers in two ways. First, you will write class Foo implements Bar out of habit and wonder why the compiler does not care when you skip it. It does not care because it checks shapes, and implements in TypeScript is just an early error if the shape drifts. Second, structural typing means two unrelated types can be accidentally compatible. A UserId and an OrderId that are both number are the same type to the compiler. PHP developers who use value objects for exactly this reason will want branded types, which are a small trick using an intersection with a phantom property.
Nullable types map exactly
This one is a relief. PHP's ?string or string|null is TypeScript's string | null. Both languages force you to handle the null case before using the value, PHP through a runtime error and TypeScript through a compile error under strictNullChecks.
function displayName(user: { name: string | null }): string {
return user.name ?? 'Anonymous';
}
The ?? operator is the same null coalescing you know from PHP 7. Optional chaining user?.profile?.city is PHP 8's nullsafe operator $user?->profile?->city with one fewer character. Even the semantics around undefined are close to how PHP treats an unset array key: there is a distinct "not there" state separate from "there but null", and TypeScript surfaces it as the undefined type. An optional property written as name?: string has the type string | undefined, not string | null. Most PHP developers conflate the two for a few days and then stop.
Union types, but everywhere
PHP 8 union types let you write int|string for a parameter. TypeScript unions go further, because the members can be literal values as well as types.
type Plan = 'free' | 'pro' | 'enterprise';
A Plan variable can hold exactly those three strings and nothing else. Assigning 'premium' is a compile error. In PHP you would reach for an enum or a class with constants and a validation method. In TypeScript, the literal union is the idiomatic tool for the small cases, and it costs nothing at runtime because the emitted JavaScript is just a string.
Enums: use them less than you expect
PHP 8.1 backed enums are excellent and you probably use them a lot. TypeScript has an enum keyword and the community advice is mostly to avoid it. Regular TypeScript enums compile to a runtime object with some odd reverse-mapping behaviour for numeric values, and const enum gets inlined by the compiler in a way that breaks under some build tools (including the transpile-only mode that Next.js uses through SWC).
The usual replacement is the literal union above, or an as const object when you need to iterate the values:
const Plan = {
Free: 'free',
Pro: 'pro',
Enterprise: 'enterprise',
} as const;
type Plan = (typeof Plan)[keyof Plan]; // 'free' | 'pro' | 'enterprise'
That second line looks alien at first. Read it as "the type of the values inside the Plan object". You get Object.values(Plan) for iteration and the union type for checking, without the enum keyword. If you miss tryFrom(), a two-line guard function does the same job.
Generics: you already know these from PHPStan
If you have annotated a collection with @template T or written @return array<int, User> in PHPStan or Psalm docblocks, you have written generics. TypeScript makes them part of the language instead of a comment.
function first<T>(items: T[]): T | undefined {
return items[0];
}
const u = first(users); // u is User | undefined
Inference does the work most of the time. You rarely need to write first<User>(users) because the compiler reads the argument type. Constraints use extends where PHPStan uses @template T of Foo. Defaults exist too: <T = string>.
The bigger shift is that TypeScript's generics are checked by the compiler that ships with the language, so there is no equivalent of "PHPStan level 9 in CI but nobody runs it locally". The types are the build.
Utility types replace abstract class boilerplate
In PHP, when you need a variant of a class (say, the same fields but everything optional for a PATCH request), you write a second class or a DTO. TypeScript has built-in type operators that derive one type from another.
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 and Readonly cover most of what you would have written as separate DTO classes. They are pure type-level constructs. No code is generated, nothing runs, and if the base User type changes the derived types follow. On the type versus interface question: for object shapes they are nearly interchangeable. Use interface for things other code will extend, and type for unions and derived types.
Strict mode is strict_types, plus more
declare(strict_types=1) stops PHP from coercing scalar types at call boundaries. TypeScript's "strict": true in tsconfig.json is a bundle of flags, and two of them matter far more than the rest. strictNullChecks is what makes string mean "definitely a string" instead of "a string, or null, or undefined, who knows". noImplicitAny refuses to let an untyped parameter silently become any, which is TypeScript's escape hatch that turns the checker off for that value.
Turn strict mode on in every new project. On an existing codebase the migration takes planning, and we have written separately about enabling strict mode incrementally. The short version for a PHP developer: any is the equivalent of removing every type hint from a function, and a codebase with any sprinkled through it has the type safety of PHP 5.
What TypeScript does that PHP cannot
Everything so far has been translation. These three features have no PHP counterpart and are the reason experienced PHP developers end up liking TypeScript rather than tolerating it.
Zero-cost types
Because types are erased at compile time, you can model your domain as precisely as you want with no runtime penalty. A PHP value object costs an allocation and a constructor call. A TypeScript branded type or a deeply nested union costs nothing after compilation. This changes how much typing you are willing to do. Modelling every API response shape in PHP means writing classes and hydrators. In TypeScript it means writing the shape once and letting inference carry it.
Discriminated unions and exhaustive matching
A discriminated union is a union of object types that share one literal property. The compiler narrows the type when you check that property.
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`;
}
}
Inside each case, result has only the properties of that branch. Access result.reason in the success branch and you get a compile error. Add a fourth status to the union and every switch that does not handle it fails to compile, if the function has a declared return type. PHP 8 enums with match get you part of the way for the enum itself, but they cannot attach different payloads to each case. In PHP you would write a class hierarchy and instanceof checks, and nothing tells you when you missed a subclass.
This pattern is everywhere in React code: component props that differ by variant, form states, request lifecycles. Once you see it, half the if ($x instanceof Y) chains in your PHP codebase start to look like discriminated unions that the language could not express.
Conditional and mapped types
Types can branch on other types. T extends string ? A : B is a type-level ternary. Mapped types iterate over the keys of another type and transform each one. Together they let library authors write things like "the return type of this function is whatever the callback returns, wrapped in a promise, with all the nullable fields made required". You will read these more than you write them, mostly in the signatures of libraries like Prisma, Drizzle and tRPC.
A first Next.js component, translated
Here is what a PHP developer's first server component tends to look like after a week of this mental model.
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>
);
}
The schema does at runtime what strict_types would have done at the boundary in PHP, and z.infer derives the static type from it so the two cannot drift. The props object is typed inline. The plan comparison against a literal is checked: misspell 'enterprize' and the build fails. None of the types survive into the browser bundle.
When to bring in help
Teams moving a PHP backend towards a Next.js frontend, or adding a TypeScript service next to a Symfony monolith, usually get the syntax right in a week and the architecture wrong for six months. The common failures are any spreading through the codebase because strict mode was off at the start, API types hand-written in two places that silently diverge, and enums ported one to one from PHP into a form the bundler mishandles. A short code review after the first few thousand lines catches those while they are still cheap to fix. For larger moves, tech stack strategy work up front decides where the type boundary between PHP and TypeScript sits, which is the decision that determines everything else.
If you are a PHP developer partway through this transition and something in your codebase does not match the model above, write to hello@wolf-tech.io or have a look at what we do at wolf-tech.io. A second pair of eyes on a tsconfig and a handful of types is a small job that saves a large one.

