DEV Community

Cover image for PHP Arrays to TypeScript: When It Is a Record, a Tuple, or a Real Type
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP Arrays to TypeScript: When It Is a Record, a Tuple, or a Real Type


You write your first TypeScript function after years of PHP.
You reach for the tool you reach for in PHP: an array. You stuff
a user id, a name, and a list of roles into it, return it, and
move on. The compiler stops you cold. There is no single type
that holds all of that, because TypeScript does not have the
PHP array.

That is the first wall every PHP developer hits, and it is the
right wall. The PHP array is one data structure doing the job of
five. It is a list, a map, a record, a tuple, and a grab-bag all
at once, and the language never makes you say which. TypeScript
makes you say which before it lets you compile.

This post is about how to translate each of those five jobs. Get
the mapping right and the rest of TypeScript stops fighting you.

The PHP array is five types wearing one mask

Here is the same array keyword doing wildly different work in
PHP.

<?php
// a list
$ids = [1, 2, 3];

// a string-keyed map
$counts = ["apples" => 4, "pears" => 2];

// a fixed record
$user = ["id" => 7, "name" => "Ana", "admin" => true];

// a positional tuple
$point = [12, 30];

// a grab-bag from the framework
$row = $db->fetch(); // who knows
Enter fullscreen mode Exit fullscreen mode

In PHP these are all array. Type a parameter as array and
you have told the reader nothing. The function that wants
$counts will happily accept $point, and the only thing that
catches the mismatch is a runtime error three calls deep.

TypeScript splits that one mask into distinct types. Each PHP
usage maps to a different TypeScript construct, and picking the
matching one is most of the work.

List, map, record, tuple

Take the five usages above one at a time.

A homogeneous list of one element type becomes an array type.

const ids: number[] = [1, 2, 3];
Enter fullscreen mode Exit fullscreen mode

A string-keyed map where keys are open-ended and every value has
the same type becomes a Record.

const counts: Record<string, number> = {
  apples: 4,
  pears: 2,
};
Enter fullscreen mode Exit fullscreen mode

A fixed set of named fields with mixed value types becomes an
object type, often named via an interface.

interface User {
  id: number;
  name: string;
  admin: boolean;
}

const user: User = { id: 7, name: "Ana", admin: true };
Enter fullscreen mode Exit fullscreen mode

A positional, fixed-length sequence becomes a tuple.

const point: [number, number] = [12, 30];
Enter fullscreen mode Exit fullscreen mode

The grab-bag from the database does not get a type by guessing.
It gets a type by validation at the boundary, which the last
section covers.

The decision is mechanical once you ask two questions: are the
keys known ahead of time, and do the values share one type?
Known keys plus mixed values means a named object type. Unknown
keys plus uniform values means a Record. Positions instead of
keys means a tuple.

Record is for open keys, not for shapes you know

This is the trap that catches PHP developers most often.
Record<string, T> looks like the natural home for any
associative array, so it gets reached for when the shape is
actually fixed.

// Wrong: the keys are known, this is not open-ended.
const user: Record<string, unknown> = {
  id: 7,
  name: "Ana",
  admin: true,
};

user.naem; // no error. typo compiles. property is unknown.
Enter fullscreen mode Exit fullscreen mode

Record<string, unknown> tells the compiler that any string is
a valid key, so a typo is a valid access and the value type is
unknown. You have thrown away every guarantee the type system
could have given you. This is the PHP array smuggled back in
under a TypeScript name.

Record earns its place when keys really are open-ended: a
counter keyed by arbitrary tag, a cache keyed by id, a lookup
built at runtime.

type Inventory = Record<string, number>;

function addStock(
  inv: Inventory,
  sku: string,
  qty: number,
): void {
  inv[sku] = (inv[sku] ?? 0) + qty;
}
Enter fullscreen mode Exit fullscreen mode

When the set of keys is finite and known, narrow the key type so
the compiler checks completeness.

type Plan = "free" | "pro" | "team";

const priceByPlan: Record<Plan, number> = {
  free: 0,
  pro: 19,
  team: 49,
};
Enter fullscreen mode Exit fullscreen mode

Drop one of the three keys and the object literal fails to
compile. Add a "max" plan to the union and every Record<Plan,
number>
in the codebase flags as incomplete. That is the PHP
config array, except the compiler now audits it for you.

Tuples replace the positional return array

PHP functions love returning a positional array and asking the
caller to know the order.

<?php
function divmod(int $a, int $b): array {
    return [intdiv($a, $b), $a % $b];
}

[$q, $r] = divmod(17, 5);
Enter fullscreen mode Exit fullscreen mode

The signature says array. It does not say "two integers, the
quotient first." The caller learns the order from a comment or
from reading the body. Swap the two return values and nothing
complains until the math is wrong in production.

The TypeScript tuple encodes the order in the type.

function divmod(a: number, b: number): [number, number] {
  return [Math.trunc(a / b), a % b];
}

const [q, r] = divmod(17, 5);
// q: number, r: number — positions are typed
Enter fullscreen mode Exit fullscreen mode

You can name the positions too, which is closer to what the PHP
comment was trying to say.

function divmod(
  a: number,
  b: number,
): [quotient: number, remainder: number] {
  return [Math.trunc(a / b), a % b];
}
Enter fullscreen mode Exit fullscreen mode

Labeled tuple elements show up in editor tooltips and make the
contract self-documenting. Return more than two or three values
and a tuple stops being the right tool. At that point you want a
named object, because callers reading result.remainder beats
callers counting commas to find position two.

The grab-bag: validate at the boundary

The last PHP usage is the honest one. The array came from
$_POST, a database row, or a JSON body, and you do not know
its shape at compile time. PHP lets you read $row["emial"],
get null, and carry on. TypeScript gives that data the type
unknown, and unknown refuses every access until you prove
the shape.

function handle(body: unknown) {
  // body.email — error: object is of type 'unknown'.
}
Enter fullscreen mode Exit fullscreen mode

The fix is the same discipline a careful PHP codebase already
uses at the edges: validate once, then trust the type inside.
The common approach is a schema validator like Zod that produces
both the runtime check and the static type from one definition.

import { z } from "zod";

const SignupSchema = z.object({
  email: z.string().email(),
  age: z.number().int().min(0),
  admin: z.boolean().default(false),
});

type Signup = z.infer<typeof SignupSchema>;

function handle(body: unknown): Signup {
  return SignupSchema.parse(body);
}
Enter fullscreen mode Exit fullscreen mode

Past parse, the value has type Signup, the typo signup.emial
is a compile error, and you never wrote the type by hand. One
schema is the source of truth for both the runtime guard and the
static type.

This is the cleanest part of the move. The PHP grab-bag array
was always a liability you patched with isset checks scattered
across the request handler. TypeScript pushes that check to a
single boundary and hands you a typed value for the rest of the
function.

The mapping, in one table

When you stare at a PHP array and have to pick its TypeScript
shape, this is the decision.

PHP usage TypeScript
[1, 2, 3] homogeneous list number[]
open string-keyed map Record<string, V>
fixed finite keys Record<Union, V>
named fields, mixed types interface / object type
positional fixed-length tuple [A, B]
untrusted input unknown + schema parse

The friction you feel in the first week is the language asking a
question PHP let you skip: what is this array actually for. Once
you answer it at the point of declaration, the rest of the type
system starts catching the bugs that used to surface as runtime
warnings in a log nobody reads.

The move from one all-purpose array to five specific types is
the single largest mental shift coming from PHP, and PHP to
TypeScript
spends a full chapter on it before moving to the
sync-to-async paradigm and discriminated unions. If the table
above is the part you want drawn out with real request handlers
and migration examples, that is the book.

The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.

  1. TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
  2. The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
  4. PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
  5. TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)