DEV Community

Cover image for Your PHP API Sends Keys 10 Then 2. Your TypeScript Client Reads 2 Then 10
Gabriel Anhaia
Gabriel Anhaia

Posted on Edited on AI-assisted

Your PHP API Sends Keys 10 Then 2. Your TypeScript Client Reads 2 Then 10


Your PHP controller builds the shipping options for a checkout dropdown. Product wants Express on top, so you build the array in that order: ['10' => 'Express', '2' => 'Standard', 'pickup' => 'Store pickup']. The JSON leaves the server with the keys in that order. The TypeScript frontend loops over it with Object.entries, and Standard renders first.

Nobody sorted anything. Both runtimes did exactly what their specs say. A PHP array remembers insertion order for every key. A JavaScript object lists integer-like keys first, in ascending numeric order, and only then the other string keys in the order they were created.

Two languages, two meanings of "in order"

The PHP manual says it in one line: "An array in PHP is actually an ordered map." Int keys and string keys share one sequence, and that sequence is insertion order until you call a sort function.

There is one wrinkle on the way in. The same page explains that strings holding valid decimal integers are cast to int: the key "8" is stored under 8, while "08" stays a string. So '10' and '2' in your literal become int keys 10 and 2. The order survives the cast.

JavaScript objects follow a different rule. The ECMAScript spec algorithm that orders an object's own keys is OrdinaryOwnPropertyKeys, and it has three passes:

  1. Every own key that is an array index, "in ascending numeric index order".
  2. Every other string key, "in ascending chronological order of property creation".
  3. Every symbol key, also in creation order.

The APIs you reach for every day sit on top of that. The archived for-in order proposal lists Object.keys, Object.values, Object.entries, JSON.parse and JSON.stringify as for-in-ordered APIs, and MDN's for...in page puts the rule plainly: integer keys "will be traversed first in ascending order by value, then other string keys in ascending chronological order of property creation." MDN's Object.keys page shows it with { 100: "a", 2: "b", 7: "c" }, which comes back as ['2', '7', '100'].

Is either side breaking JSON? No. RFC 8259 defines an object as "an unordered collection of zero or more name/value pairs" and notes that parsers "have been observed to differ as to whether or not they make the ordering of object members visible to calling software." The wire format never promised you order. PHP happened to keep it, and JavaScript applies its own rule.

Two conveyor belts side by side: on the left, cards travel in the order they were placed; on the right, the cards marked with dots jump ahead of the plain ones and line up by size
Same cards, two rules: PHP keeps the line as built, a JavaScript object pulls integer-like keys to the front.

Watch it happen in PHP and Node

Here is the PHP side. Save it as order.php and run php order.php:

<?php
$options = [
    '10' => 'Express',
    '2' => 'Standard',
    'pickup' => 'Store pickup',
];

var_dump(array_keys($options));
// int 10, int 2, string "pickup": insertion order

echo json_encode($options), PHP_EOL;
// a JSON object, keys written as 10, 2, pickup
Enter fullscreen mode Exit fullscreen mode

json_encode writes an object here because, in the manual's words, "if the keys are not a continuous numeric sequence starting from 0, all keys are encoded as strings, and specified explicitly for each key-value pair." Keys 10 and 2 are not a sequence from 0, so you get an object, in PHP's order.

Now the client. Save it as order.mjs and run it with Node.js:

const body =
  '{"10":"Express","2":"Standard","pickup":"Store pickup"}';
const options = JSON.parse(body);

for (const [id, label] of Object.entries(options)) {
  console.log(id, label);
}
// spec order: "2" first, then "10", then "pickup"
Enter fullscreen mode Exit fullscreen mode

The text on the wire still says 10 first. The object built from it lists 2 first, because "2" and "10" are array indices and "pickup" is not. Round-trip it through JSON.stringify and the reordered version is what goes back out: MDN's JSON.stringify page says properties are visited "using the same algorithm as Object.keys()".

TypeScript cannot warn you. JSON.parse returns any, and a Record<string, string> describes both orders identically. Key order is not part of the type system.

Which keys move, exactly

The spec's definition of array index is narrower than "looks like a number". It is a key whose canonical numeric form is an integer from 0 up to 2^32 − 2. That gives you a few edge cases worth knowing:

  • "01" is not in canonical form, so it keeps creation order. PHP agrees here: per the manual, "08" stays a string key too.
  • "-1" and "1.5" are not array indices (one is negative, the other is not an integer), so they keep creation order.
  • "4294967295" (2^32 − 1) and anything larger keeps creation order.

So the reshuffle depends on the values of your keys. A map keyed by small auto-increment IDs gets sorted. The same map keyed by slugs or 64-bit snowflake IDs keeps its order. One endpoint can behave differently in staging and production because the data is different.

The sibling trap: array_filter turns a list into an object

The same rule in json_encode causes a second bug, and this one breaks code instead of reordering it. array_filter does not renumber: "Array keys are preserved, and may result in gaps if the array was indexed." A list with a gap is no longer a continuous sequence from 0, so it goes out as an object.

The manual's own json_encode example shows it: after unset($sequential[1]), the array ["foo","bar","baz","blong"] encodes as {"0":"foo","2":"baz","3":"blong"}.

A train of connected carriages with one carriage lifted out, leaving a gap, while the remaining carriages fold into a cabinet of separate drawers
Remove one item from a PHP list and json_encode stops sending a list.

<?php
$tags = ['php', 'typescript', 'go'];

$kept = array_filter($tags, fn ($t) => $t !== 'php');
echo json_encode($kept), PHP_EOL;
// keys 1 and 2 survive: a JSON object, not an array

echo json_encode(array_values($kept)), PHP_EOL;
// keys reset to 0 and 1: a JSON array
Enter fullscreen mode Exit fullscreen mode

The nasty part is that the shape depends on the data. Filter out 'go' instead and keys 0 and 1 remain, which is still a list, so you get an array. Your TypeScript client calls data.tags.map(...), it works for weeks, and then one request drops the first element and the call throws a TypeError, because a plain object has no map method.

It runs the other way too. The json_encode page shows that an empty array encodes as [], so a PHP map that happens to be empty reaches the client as an array, not {}. JSON_FORCE_OBJECT exists, but it is a blunt switch: the same example turns a nested list [[1,2,3]] into {"0":{"0":1,"1":2,"2":3}}.

Fix it on the PHP side

Treat the JSON shape as part of the API contract and make it explicit.

For lists, call array_values after anything that can leave gaps: array_filter, unset, array_unique. The array_filter manual page points to it for exactly this. On PHP 8.1+, array_is_list checks whether keys are "consecutive numbers from 0 to count($array)-1", which makes a cheap assertion in a response test.

For maps where order matters, stop sending a JSON object. Send a list of records:

<?php
$options = [
    10 => 'Express',
    2 => 'Standard',
    'pickup' => 'Store pickup',
];

$payload = [];
foreach ($options as $id => $label) {
    $payload[] = ['id' => (string) $id, 'label' => $label];
}

echo json_encode($payload), PHP_EOL;
// a JSON array of {id, label} objects, Express first
Enter fullscreen mode Exit fullscreen mode

The (string) cast matters. PHP already turned '10' into int 10, so without it id is a JSON number for numeric keys and a string for 'pickup'. Your TypeScript type would have to be number | string. Cast once on the server and the client gets one type.

Fix it on the TypeScript side

Type the payload as the list it is, and build a Map if you need lookups by ID. MDN states that a Map "remembers the original insertion order of the keys", and that holds for "10" and "2" as much as for "pickup":

type ShippingOption = { id: string; label: string };

async function loadOptions(): Promise<Map<string, string>> {
  const res = await fetch('/api/shipping-options');
  const list = (await res.json()) as ShippingOption[];
  return new Map(list.map((o) => [o.id, o.label]));
}
Enter fullscreen mode Exit fullscreen mode

The same MDN page is blunt about plain objects: their order "is complex. As a result, it's best not to rely on property order." Use objects for lookup and arrays or Maps for anything a user sees in sequence.

When you don't own the PHP side and the list-or-object flip is already in production, normalise at the boundary:

function asList<T>(value: unknown): T[] {
  if (Array.isArray(value)) return value as T[];
  if (value !== null && typeof value === 'object') {
    return Object.values(value) as T[];
  }
  throw new TypeError('expected a list or an object');
}
Enter fullscreen mode Exit fullscreen mode

This is safe for the array_filter case: the surviving keys were already ascending, so the ascending integer order of Object.values matches the original list. It cannot rescue the shipping dropdown. Once JSON.parse turns a map with integer keys inserted out of order into a JavaScript object, the order is gone. Short of parsing the raw response text yourself, only a change to the payload brings it back.


If this was useful

PHP arrays do the work of lists, maps and ordered maps all at once, and both bugs in this post come from that one structure being split into three on the TypeScript side. PHP to TypeScript walks through that split for PHP 8+ developers: when the data is an array, a Map, a Record or a tuple, and how to keep the JSON contract between the two honest.

If you came from PHP, start with PHP to TypeScript, then go to The TypeScript Type System when you want the deep end. Books 1 and 2 are the core path for everyone else, and TypeScript in Production is for the day you ship it at work.

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

The TypeScript Library — the 5-book collection

Top comments (0)