Skip to main content
Signocore
WordPress PHP Web Development 8 min read Daniel Nielsen

TypeScript for PHP Developers: A Practical Introduction

If you write typed PHP, you already think in types. Here's how to transfer that discipline to TypeScript - without starting from scratch.

TypeScript for PHP Developers: A Practical Introduction

You've been writing string $title, int $count, and ?array $meta in your PHP for years. You annotate return types. You use PHPDoc blocks where the type system can't reach. You care about correctness. And then you open a JavaScript file and everything goes feral - no types, no contracts, no guardrails.

TypeScript fixes that. But here's the thing most TypeScript tutorials miss: if you're a PHP developer who already writes typed code, you don't need a conceptual introduction to types. You need a translation guide. The ideas map almost directly. The syntax is just different.

This is that guide.

The Mental Bridge: PHP Types to TypeScript

PHP 8.x and TypeScript solve overlapping problems from different directions. PHP added types to a dynamically-typed runtime. TypeScript adds types to JavaScript - which is also dynamically typed at runtime. Both are opt-in type systems layered on top of existing languages. Both disappear at execution time. That symmetry matters.

Start with the basics. In PHP you write:

function getPostTitle(int $postId): string {
    return get_the_title($postId);
}

In TypeScript:

function getPostTitle(postId: number): string {
    return document.querySelector(`[data-id="${postId}"]`)?.textContent ?? '';
}

The structure is nearly identical. Parameters typed first, return type declared. The mental model transfers immediately.

Interfaces as Contracts

In PHP, interfaces define method contracts that classes must implement. TypeScript interfaces do the same - but they also describe the shape of data, which is something PHP can't express natively without classes or PHPDoc arrays.

// PHP - you'd use a class or a PHPDoc shape annotation
/** @param array{id: int, title: string, published: bool} $post */
function renderPost(array $post): void { ... }

// TypeScript - a proper interface
interface Post {
    id: number;
    title: string;
    published: boolean;
}

function renderPost(post: Post): void { ... }

If you've been writing @param array{key: type} PHPDoc annotations, you've already been thinking in TypeScript interfaces. The only difference is that TypeScript enforces them at compile time instead of leaving it to your IDE's static analysis.

Nullable Types and Union Types

PHP's nullable syntax - ?string - maps directly to TypeScript's union type:

// PHP
function findPost(?int $id): ?string { ... }

// TypeScript
function findPost(id: number | null): string | null { ... }

TypeScript's union types are actually more expressive. You can union any set of types, not just nullable primitives. string | number | boolean is valid. So is Post | DraftPost | null. Once you see this, PHP's type system starts to feel limited by comparison.

Generics

PHP has generics via PHPDoc - @template T, @return Collection<T>. TypeScript has first-class generics baked into the language:

// TypeScript generic function
function first<T>(items: T[]): T | null {
    return items.length > 0 ? items[0] : null;
}

const post = first<Post>(posts); // TypeScript knows this is Post | null

If you've used PHPStan or Psalm with generics, this will feel familiar. If you haven't, start here - generics are where TypeScript's type system starts to pay serious dividends.

Setting Up TypeScript in a WordPress Plugin or Theme

The WordPress ecosystem has a first-class TypeScript story, and it's built into @wordpress/scripts. If you're already using wp-scripts for your build process, TypeScript support is one config file away.

First, add TypeScript as a dev dependency:

npm install --save-dev typescript

Then create a tsconfig.json at your plugin root. A sensible starting point for WordPress development:

{
    "compilerOptions": {
        "target": "ES2017",
        "module": "ESNext",
        "moduleResolution": "bundler",
        "strict": true,
        "jsx": "react",
        "jsxImportSource": "@wordpress/element",
        "paths": {
            "@wordpress/*": ["./node_modules/@wordpress/*/src"]
        }
    },
    "include": ["src/**/*.ts", "src/**/*.tsx"],
    "exclude": ["node_modules", "build"]
}

The strict: true flag is worth enabling from day one. It turns on strictNullChecks, noImplicitAny, and several other checks that catch real bugs. Yes, it generates more errors initially. Those errors are real problems - not false positives.

Rename your .js files to .ts (or .tsx for JSX/Gutenberg blocks). wp-scripts build handles TypeScript compilation automatically via its underlying webpack configuration. No additional webpack config is needed for basic setups.

For Gutenberg block development, install the WordPress type definitions:

npm install --save-dev @types/wordpress__blocks @wordpress/block-editor

These give you typed access to the entire WordPress JavaScript API - wp.data, wp.blocks, wp.apiFetch, and more. When you call wp.apiFetch, TypeScript knows what it returns. That's a significant upgrade over guessing the shape of the response.

Patterns PHP Developers Will Recognise

Enums

PHP 8.1 introduced native enums. TypeScript has had them for years, though the idiomatic modern approach uses const objects or string literal unions instead of enum declarations:

// TypeScript string literal union (preferred modern approach)
type PostStatus = 'publish' | 'draft' | 'pending' | 'trash';

function updateStatus(id: number, status: PostStatus): void { ... }

// TypeScript will reject this:
updateStatus(42, 'published'); // Error: Argument of type '"published"' is not assignable

This is tighter than PHP's string parameters because TypeScript catches the typo at compile time. PHP would only catch it at runtime if you added explicit validation logic.

Readonly Properties

PHP 8.1 added readonly properties. TypeScript has the same concept:

interface SiteConfig {
    readonly siteUrl: string;
    readonly apiNonce: string;
    adminEmail: string; // mutable
}

const config: SiteConfig = window.myPluginConfig;
config.siteUrl = 'https://other.com'; // Error: Cannot assign to 'siteUrl'

This pattern is particularly useful for WordPress plugin configuration objects passed from PHP to JavaScript via wp_localize_script - values that should be treated as immutable after initialisation.

Where TypeScript Catches Errors PHP Wouldn't

This is where the investment pays off. PHP's type system protects your server-side code. TypeScript protects territory PHP can't reach.

The DOM

document.querySelector returns Element | null. TypeScript forces you to handle the null case before calling methods on the result. PHP has no equivalent - there's no DOM on the server. Every unchecked querySelector in vanilla JavaScript is a potential runtime error. TypeScript makes those errors visible before the code ships.

const button = document.querySelector('.my-button');

// TypeScript error: Object is possibly 'null'
button.addEventListener('click', handler);

// Correct - with a null check
button?.addEventListener('click', handler);

// Or with a type assertion when you're certain it exists
const button = document.querySelector<HTMLButtonElement>('.my-button')!;

Async Operations and API Responses

When you call wp.apiFetch or fetch the WordPress REST API, you get back a JSON blob. Without TypeScript, you're trusting that the response matches your assumptions. With TypeScript:

interface WPPost {
    id: number;
    title: { rendered: string };
    content: { rendered: string };
    status: PostStatus;
}

const post = await apiFetch<WPPost>({ path: '/wp/v2/posts/1' });
// TypeScript now knows post.title.rendered is a string

This doesn't validate the response at runtime - TypeScript types are erased at compilation. But it documents the expected shape, catches access errors during development, and flags mismatches when the API response type changes. For runtime validation, pair this with a library like Zod.

JSON Shape Assumptions

PHP developers frequently pass configuration from PHP to JavaScript via wp_localize_script. The JavaScript side has no idea what shape that object is. TypeScript lets you declare it explicitly:

declare global {
    interface Window {
        myPluginData: {
            ajaxUrl: string;
            nonce: string;
            postId: number;
            settings: Record<string, unknown>;
        };
    }
}

// Now TypeScript knows the shape of window.myPluginData
const { ajaxUrl, nonce } = window.myPluginData;

If you're working with complex JSON structures, the JSON to TypeScript converter tool can generate interface definitions from a sample JSON payload - useful for quickly typing REST API responses.

Common Friction Points

TypeScript is not frictionless. Being honest about the rough edges saves you from abandoning it at the first obstacle.

  • Type inference isn't always what you expect. TypeScript infers types aggressively, which is mostly helpful. But inferred types can be broader than you intend - const status = 'draft' infers as string, not 'draft'. Use as const or explicit type annotations when precision matters.

  • Declaration files for third-party libraries. Not every npm package ships TypeScript types. When they don't, you'll encounter Could not find a declaration file for module 'x'. Check for a corresponding @types/x package first. If none exists, you can write a minimal .d.ts declaration file or use declare module 'x' as a temporary escape hatch.

  • The any escape hatch is real. TypeScript lets you opt out of type checking with any. This is tempting when a type is hard to express. Resist it. unknown is almost always the right choice instead - it forces you to narrow the type before using the value.

  • WordPress globals can be messy. The global wp object is large and partially typed. The @wordpress/* packages each ship their own types, but coverage is uneven. Expect to write some declaration files for older WordPress APIs.

When TypeScript Is Worth It for WordPress Development

TypeScript adds a build step and upfront investment. That cost is not always justified. Here's an honest assessment.

TypeScript earns its place when: you're building Gutenberg blocks with significant JavaScript logic, developing a plugin with a JavaScript-heavy admin interface, working on a team where the JavaScript codebase needs to be readable and maintainable by multiple developers, or building anything that makes frequent REST API calls where the response shape matters.

TypeScript is probably overkill when: you're writing a few dozen lines of jQuery to handle a simple form interaction, the JavaScript in your plugin is essentially configuration with no real logic, or the project is a one-person effort with a short lifespan and no expectation of maintenance.

The dividing line is roughly: does your JavaScript have logic? Does it handle state, make decisions, transform data, or interact with APIs? If yes, TypeScript pays for itself. If your JavaScript is mostly event listeners and DOM manipulation with no real branching logic, plain JavaScript with JSDoc type hints might be sufficient.

The misconception to avoid is treating TypeScript as an all-or-nothing commitment. You can add TypeScript incrementally - start with new files, leave old ones as .js, and set "allowJs": true in your tsconfig. TypeScript will coexist with JavaScript in the same project.

One last thing worth noting: if you're building WordPress plugins with any meaningful front-end complexity, the investment in TypeScript compounds. The larger the codebase grows, the more the type system earns its keep - catching regressions during refactors, documenting intent for future maintainers, and flagging API contract violations before they reach production. PHP developers who've seen what strict types do for large PHP codebases already understand this dynamic. TypeScript brings the same discipline to the front end - and that's not a small thing.

// keep reading

Have questions about this article?

Get in touch if you'd like to learn more about this topic.

September Sale

€20 off Signocore SEO Pro

Pay €49 instead of €69, one time for unlimited sites. code SEP20