- Book: PHP to TypeScript — A Bridge for Modern PHP 8+ Developers
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You write enum Status in TypeScript and assume the bridge from PHP is built. It is not. PHP's enum is a runtime class with method dispatch and exhaustive match. TypeScript's enum is a compile-time hint that emits an unwanted IIFE. Same keyword, different semantics. The TypeScript handbook documents the pitfalls of enum and points to literal-type unions as the alternative for almost every case.
There is a clean translation. It is not the keyword. It is a discriminated union plus a same-named namespace that holds the companion methods. The values live in the type. The methods sit in a same-named namespace next to it. At the call site, OrderStatus.label(status) looks almost exactly like Status::Active->label() did in PHP.
Here is the PHP shape, three TS replacements, and the decision rule.
The PHP enum we are translating
<?php
declare(strict_types=1);
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Awaiting payment',
self::Paid => 'Payment received',
self::Shipped => 'In transit',
self::Cancelled => 'Cancelled',
};
}
public function isTerminal(): bool
{
return match ($this) {
self::Shipped, self::Cancelled => true,
default => false,
};
}
public static function fromLabel(string $label): self
{
return match ($label) {
'Awaiting payment' => self::Pending,
'Payment received' => self::Paid,
'In transit' => self::Shipped,
'Cancelled' => self::Cancelled,
};
}
}
$status = OrderStatus::Paid;
echo $status->label();
echo $status->isTerminal() ? 'done' : 'in flight';
Four cases with backing strings. Two instance methods. One static factory. The match is exhaustive — drop a case, the static analyzer fails the build. Change the enum, and every branch that switches on it has to be updated. That is the contract worth preserving.
Pattern 1: TypeScript enum (avoid in 2026)
The PHP reflex translates the keyword first. It compiles and ships. Six months later, the team is rewriting it.
export enum OrderStatus {
Pending = 'pending',
Paid = 'paid',
Shipped = 'shipped',
Cancelled = 'cancelled',
}
function isTerminal(s: OrderStatus): boolean {
switch (s) {
case OrderStatus.Shipped:
case OrderStatus.Cancelled:
return true;
default:
return false;
}
}
The list of reasons not to use this in new code is long enough that the TypeScript handbook itself documents the pitfalls and points readers at alternatives. The compiler emits a runtime object even when the rest of your code is import type-only, breaking tree-shaking for libraries. Numeric enums let any number be assigned to the enum type, including ones you never declared. const x: Direction = 99 type-checks. The const enum workaround inlines values but breaks under isolatedModules, which is the default in most modern toolchains (esbuild, swc, Bun, Vite). Reverse mappings for numeric enums add bytes you did not ask for. And the structural typing you came to TypeScript for goes nominal the moment enum shows up. Two enums with the same shape are not assignable to each other.
The handbook's guidance lines up with what experienced TS reviewers will tell you: literal unions for new code, migrate existing enums when you touch them. PHP's enum is a real, sound type. TypeScript's enum is the same keyword wired to different, weaker semantics.
Pattern 2: const-object plus union type (good — but methods live elsewhere)
Drop the keyword. Use a const object frozen by as const and derive the union type from its values. This is the pattern most TypeScript style guides reach for first.
export const OrderStatus = {
Pending: 'pending',
Paid: 'paid',
Shipped: 'shipped',
Cancelled: 'cancelled',
} as const;
export type OrderStatus =
typeof OrderStatus[keyof typeof OrderStatus];
// 'pending' | 'paid' | 'shipped' | 'cancelled'
function isTerminal(s: OrderStatus): boolean {
switch (s) {
case OrderStatus.Shipped:
case OrderStatus.Cancelled:
return true;
default: {
const _exhaustive: 'pending' | 'paid' = s;
return false;
}
}
}
The dual declaration trick (same name for the value and the type) works because TypeScript has separate value and type namespaces. OrderStatus.Paid reads at runtime like OrderStatus::Paid did in PHP. Assignability is structural and exhaustiveness checks work in switch. There is no runtime artifact beyond the four-key object.
What this pattern does not give you is methods. label() and isTerminal() have nowhere to live. You can hang them on the object literal, but then the object's value-type widens awkwardly and the union derivation breaks. Standalone functions that accept an OrderStatus work, but status.label() no longer auto-completes. For an enum that is just values, this pattern is enough. For one that carries behaviour, you want pattern 3.
Pattern 3: discriminated union plus same-name namespace (the migration target)
This is the closest TypeScript shape to a PHP backed enum with methods. The values live in a string-literal union. A namespace declared with the same identifier holds the companion methods. At the call site, Status::Active->label() becomes OrderStatus.label(status) — same characters, no class machinery.
export type OrderStatus =
| 'pending'
| 'paid'
| 'shipped'
| 'cancelled';
export namespace OrderStatus {
export const Pending: OrderStatus = 'pending';
export const Paid: OrderStatus = 'paid';
export const Shipped: OrderStatus = 'shipped';
export const Cancelled: OrderStatus = 'cancelled';
export function label(s: OrderStatus): string {
switch (s) {
case 'pending': return 'Awaiting payment';
case 'paid': return 'Payment received';
case 'shipped': return 'In transit';
case 'cancelled': return 'Cancelled';
}
}
export function isTerminal(s: OrderStatus): boolean {
switch (s) {
case 'shipped':
case 'cancelled':
return true;
case 'pending':
case 'paid':
return false;
}
}
export function fromLabel(label: string): OrderStatus {
switch (label) {
case 'Awaiting payment': return 'pending';
case 'Payment received': return 'paid';
case 'In transit': return 'shipped';
case 'Cancelled': return 'cancelled';
default:
throw new Error(`Unknown label: ${label}`);
}
}
}
Read the call site:
import { OrderStatus } from './order-status';
const status: OrderStatus = OrderStatus.Paid;
console.log(OrderStatus.label(status)); // "Payment received"
if (OrderStatus.isTerminal(status)) {
// ...
}
OrderStatus.Paid returns the literal 'paid' typed as OrderStatus. OrderStatus.label is a free function the namespace exposes alongside the constants. It looks like a static method call because PHP's :: and TypeScript's . on a namespace import are visually identical. The discriminated-union exhaustiveness checks work. Drop a case from the type, every switch lights up red.
Three properties this pattern earns that the previous two did not:
Tree-shaking works. The namespace compiles to an object literal where each export is independently reachable. A bundler that sees import { OrderStatus } from './order-status' followed only by OrderStatus.Paid can drop label, isTerminal, and fromLabel from the output. A TS enum cannot do that. Its emitted IIFE binds all members in one closure.
No runtime class. The namespace is a plain object. There is no instanceof OrderStatus and no constructor cost. Compared to a Java-style class enum, the runtime footprint is zero beyond the four string literals and three functions you actually wrote.
Type and value coexist by name. OrderStatus works as a type annotation (const x: OrderStatus) and as a value namespace (OrderStatus.Paid, OrderStatus.label(x)) in the same file. This is the same trick pattern 2 uses, extended to companion methods.
Namespaces have a dated reputation in the TypeScript community. The official advice for application code organization is "prefer modules to namespaces" — and that advice is correct for modules. Namespaces shine specifically when you want a value-type pair sharing one name, which is the enum-with-methods case. The TypeScript compiler itself uses this pattern internally for the same reason. If a reviewer pushes back, show them the bundle output.
Why not the class-enum reflex Java devs reach for
A developer coming from Java often translates a PHP enum to a class:
class OrderStatus {
static readonly Pending = new OrderStatus('pending');
static readonly Paid = new OrderStatus('paid');
static readonly Shipped = new OrderStatus('shipped');
static readonly Cancelled = new OrderStatus('cancelled');
private constructor(public readonly value: string) {}
label(): string { /* ... */ }
isTerminal(): boolean { /* ... */ }
}
This is the Java enum pattern faithfully reproduced. It works. It is also wrong for TypeScript for three concrete reasons.
The type OrderStatus is now a class, not a union. Exhaustiveness checks against 'pending' | 'paid' | 'shipped' | 'cancelled' no longer work, because each case is a singleton instance of the class — the compiler cannot enumerate them. You can switch on status.value, but at that point you are pattern-matching a string anyway, so the class wrapper bought you nothing.
JSON serialization breaks. JSON.stringify({ status: OrderStatus.Paid }) produces {"status":{"value":"paid"}}, not {"status":"paid"}. Every API response, every localStorage write, every database column needs a .value unwrap or a custom toJSON. PHP backed enums serialize cleanly because they unwrap to their string in JSON encoding. TS class enums do not.
Equality is reference-based. OrderStatus.Paid === fetchedStatusFromAPI returns false, because the API response is the string 'paid' and the static field is a class instance. You end up writing a fromString factory and threading it through every boundary. The discriminated-union version compares with === against the literal and is done.
Java's enum makes sense in a language with no structural typing and no literal types. TypeScript already has both, so the class shape is pure overhead.
The migration recipe
For each PHP enum class:
- Copy the case names and their backing values into a string-literal union type. The type name matches the PHP class name.
- Open a
namespacewith the same name. Inside, declare aconstfor each case (Pending,Paid, ...) typed as the union. The capitalised name preserves theStatus::Activelook at the call site. - Translate each instance method (
->label(),->isTerminal()) into a free function inside the namespace whose first parameter is the union type. The PHPmatch ($this)becomes a TSswitch (s). - Translate each static method (
Status::fromLabel()) into a function inside the namespace. No first-parameter convention needed. - Replace
Status::ActivewithStatus.Activeand$status->label()withStatus.label(status)at every call site. The rewrite is mechanical for zero-arg method calls; multi-arg calls need a manual pass to thread the receiver in as the first argument. - If your editor flags
namespaceas deprecated, the rule is "prefer modules to namespaces" — and it is correct for modules. The merged value-and-type case is the documented exception.
The PHP exhaustive match and the TS exhaustive switch over a union are the same contract: the compiler refuses to let you forget a case. Lose the exhaustiveness, and you lose the only reason the migration was safe.
Decision rule
-
Pattern 1 (TS
enum): never in new code. Migrate existing ones when you touch them. - Pattern 2 (const-object + union type): when the enum carries values only and no methods. The lightest-weight option.
-
Pattern 3 (union + same-name namespace): when the PHP enum had methods. This is the migration target for any backed enum with
label(),isTerminal(),fromLabel()-style behaviour. - Class-enum: when you are writing Java in TypeScript and have not yet noticed.
Two of these (the const-object and the namespace pattern) cost zero TypeScript wizardry. They look like the original PHP at the call site. They behave better under bundling, JSON serialization, and equality checks. Pick whichever matches whether the enum carries behaviour or not, and stop reaching for the keyword that says enum.
If this was useful
PHP to TypeScript in The TypeScript Library walks the full bridge: backed enums to discriminated unions, traits to mixins, magic methods to proxies, attributes to decorators, and the parts of generics PHP 8+ did not prepare you for. It is one of five books in the collection:
- TypeScript Essentials — entry point. Types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, the browser.
-
The TypeScript Type System — the deep dive. Generics, mapped and conditional types,
infer, template literals, branded types. - Kotlin and Java to TypeScript — bridge for JVM developers. Variance, null safety, sealed-to-unions, coroutines to async/await.
- PHP to TypeScript — bridge for PHP 8+ developers. The book this post came from.
- TypeScript in Production — the production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
Books 1 and 2 are the core path. Book 4 substitutes for 1 if you speak PHP. Book 5 is for anyone shipping TypeScript at work.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)