Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

strlen Says 4, .length Says 2: PHP Byte Strings vs TypeScript UTF-16 for One Emoji

Book: PHP to TypeScript 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 | GitHu

strlen Says 4, .length Says 2: PHP Byte Strings vs TypeScript UTF-16 for One Emoji

Ask PHP how long a thumbs-up emoji is and strlen says 4. Ask mb_strlen and it says 1. Move the same validation rule to TypeScript and .length says 2. Spread it into an array and you get 1 again. Four answers for one character on the screen. Each function is counting a different unit.

If you are porting PHP to TypeScript, this is one of the first places where code that looks like a line-for-line translation quietly changes behaviour. A "max 280 characters" check, a username truncation, a "reverse this string" helper in a test fixture: each one measures something else after the move. The fixes on the TypeScript side are short once you know which unit you need.

Two languages, two units

PHP's manual is blunt about it: "a character is the same as a byte," and "internally, PHP strings are byte arrays" (PHP manual, Strings). PHP does not know what encoding a string is in. It holds whatever bytes your source file or your request body had, which these days is almost always UTF-8. The strlen docs say it returns "the number of bytes rather than the number of characters."

JavaScript, and so TypeScript, stores strings as UTF-16. MDN defines length as the length of the string "in UTF-16 code units." Characters outside the Basic Multilingual Plane, which includes nearly every emoji, take two code units: a surrogate pair. TypeScript adds nothing here: the string type carries no encoding information, and you get the JavaScript runtime's behaviour.

The thumbs-up is U+1F44D. In UTF-8 it is four bytes, F0 9F 91 8D. In UTF-16 it is the surrogate pair D83D DC4D (Compart, U+1F44D). Those two encodings explain every result below.

Two rulers measuring the same thumbs-up icon, one marked in four short byte ticks, the other in two longer code-unit ticks
Same emoji, two rulers: PHP measures bytes, TypeScript measures UTF-16 code units.

Counting length

Every snippet in this post runs as a one-liner with PHP's CLI (php -r) or Node.js (node -e). Both use the \u{...} escape so you don't have to paste an emoji into your terminal. In PHP's double-quoted strings it writes the code point's UTF-8 bytes (PHP manual, Strings); in JavaScript it writes the UTF-16 code units.

php -r '$s = "\u{1F44D}";
echo strlen($s), " ", mb_strlen($s, "UTF-8"), PHP_EOL;'
# 4 1

node -e 'const s = "\u{1F44D}";
console.log(s.length, [...s].length);'
# 2 1

strlen counts the four UTF-8 bytes. mb_strlen (from the mbstring extension) counts code points, and "a multi-byte character is counted as 1." .length counts the two UTF-16 code units. Spreading the string uses the string iterator, which walks code points, so [...s].length is 1. MDN recommends it when you want a character count.

So mb_strlen and [...s].length agree. Port a PHP length check to that pair, and leave strlen and .length out of it.

Now add a skin tone. 👍🏽 is U+1F44D followed by U+1F3FD, the Fitzpatrick type-4 modifier, itself four UTF-8 bytes F0 9F 8F BD and the surrogate pair D83C DFFD (Compart, U+1F3FD). A reader sees one thumb. Here is what each function sees:

Function 👍 👍🏽 Unit
PHP strlen 4 8 UTF-8 bytes
PHP mb_strlen 1 2 code points
PHP grapheme_strlen 1 1 grapheme clusters
TS .length 2 4 UTF-16 code units
TS [...s].length 1 2 code points
TS Intl.Segmenter 1 1 grapheme clusters

The grapheme row is what a human means by "character." On the PHP side that is grapheme_strlen from the intl extension. On the TypeScript side it is Intl.Segmenter, whose default granularity is "grapheme":

const seg = new Intl.Segmenter();

function graphemeLength(s: string): number {
  return [...seg.segment(s)].length;
}

graphemeLength("\u{1F44D}\u{1F3FD}"); // 1

Create the segmenter once and reuse it. It carries locale data, so building one per call is wasteful.

Cutting a string in half

Truncation is where the counting difference turns into broken data. Cut two units off the front in each language:

php -r 'echo bin2hex(substr("\u{1F44D}", 0, 2)), PHP_EOL;'
# f09f

node -e 'const half = "\u{1F44D}".slice(0, 1);
console.log(half.charCodeAt(0).toString(16));
console.log(half.isWellFormed());'
# d83d
# false

PHP's substr hands you the first two bytes of a four-byte sequence, which is invalid UTF-8. The mb_strcut docs describe exactly this: substr cuts the string between the bytes, which would "result in a malformed byte sequence."

TypeScript's slice(0, 1) gives you a lone high surrogate. isWellFormed() returns false for strings with lone surrogates, and it has been available across browsers since October 2023 (Node 20+ on the server). The same MDN page shows encodeURI throwing URIError: URI malformed on such a string, so a truncated search term can blow up a URL builder far from where the cut happened.

The quieter failure is serialisation. TextEncoder.encode takes a USVString (WHATWG Encoding spec), and converting a string to a USVString replaces lone surrogates with U+FFFD (Web IDL). So the half-emoji goes over the wire as EF BF BD, the replacement character, with no error anywhere. Your PHP backend receives valid UTF-8 that says "�".

A pair of scissors cutting a thumbs-up icon down the middle, the left half falling away as a broken fragment
substr and slice cut on bytes and code units, wherever the reader's character happens to sit.

The fix in TypeScript is to cut on grapheme boundaries:

const seg = new Intl.Segmenter();

function truncateGraphemes(s: string, max: number): string {
  let out = "";
  let n = 0;
  for (const { segment } of seg.segment(s)) {
    if (n === max) break;
    out += segment;
    n++;
  }
  return out;
}

The PHP equivalent is mb_substr if code points are enough, or grapheme_substr from intl, which counts in grapheme units, if you want the skin tone to stay attached.

Reversing a string

The classic interview helper breaks in both languages, in different ways:

php -r 'echo bin2hex(strrev("\u{1F44D}")), PHP_EOL;'
# 8d919ff0

node -e 'const r = "\u{1F44D}".split("").reverse().join("");
console.log(r.isWellFormed());'
# false

strrev reverses bytes, because the bytes are all PHP sees. 8D 91 9F F0 is not UTF-8. split("") splits into UTF-16 code units, so reversing swaps the two halves of the surrogate pair into an order that no longer forms a pair.

Spreading fixes the single emoji, since [..."\u{1F44D}"] is one element. It does not fix 👍🏽: spreading gives two code points, and reversing them puts the skin-tone modifier in front of the thumb, which usually renders as a colour swatch followed by a default yellow thumb. The grapheme-correct version reuses the segmenter:

function reverseGraphemes(s: string): string {
  return Array.from(seg.segment(s), (x) => x.segment)
    .reverse()
    .join("");
}

PHP 8.4 added grapheme_str_split, so the PHP mirror is implode("", array_reverse(grapheme_str_split($s))).

When the limit is in bytes

Sometimes you really do want PHP's strlen behaviour. Storage and wire limits are often in bytes. MySQL's storage requirements page says VARCHAR(M) counts characters, but binary types count bytes, utf8mb4 needs up to four bytes per character, and every row shares a 65,535-byte maximum. That is why a utf8mb4 VARCHAR tops out at 16,383 characters. A VARBINARY(255) column, a fixed-size protocol field or a header with a byte cap all measure bytes, and .length is the wrong number for every one of them.

In TypeScript, TextEncoder returns a Uint8Array of the UTF-8 bytes, so its length is the number PHP's strlen would give you for the same UTF-8 string:

const enc = new TextEncoder();

enc.encode("\u{1F44D}").length;           // 4
enc.encode("\u{1F44D}\u{1F3FD}").length;  // 8

To truncate to a byte budget without cutting a grapheme, combine the two:

const seg = new Intl.Segmenter();
const enc = new TextEncoder();

function truncateBytes(s: string, maxBytes: number): string {
  let out = "";
  let used = 0;
  for (const { segment } of seg.segment(s)) {
    const size = enc.encode(segment).length;
    if (used + size > maxBytes) break;
    out += segment;
    used += size;
  }
  return out;
}

truncateBytes("ok\u{1F44D}\u{1F3FD}", 5); // "ok"

"ok" is two bytes and the thumb with its skin tone is eight, so a 5-byte budget keeps only "ok". The PHP side already has this as one call: grapheme_extract with GRAPHEME_EXTR_MAXBYTES returns whole grapheme clusters that fit the byte budget. If you only need code-point safety, mb_strcut moves a cut that lands inside a multibyte character back to that character's first byte.

The porting checklist

When a PHP string function crosses into TypeScript, ask which unit the original code meant, not which function has the same shape:

  • strlen used as a byte budget becomes new TextEncoder().encode(s).length.
  • mb_strlen becomes [...s].length.
  • grapheme_strlen, or any "characters as the user sees them" rule, becomes Intl.Segmenter.
  • substr and mb_substr on user text become a grapheme loop, and isWellFormed() is a cheap assertion at the boundary where strings leave your service.

The last one is the guard rail. If a string about to be sent, stored or URL-encoded fails isWellFormed(), something upstream cut it on a code unit, and you'd rather learn that from a failing check than from a "�" in a customer's display name.

If this was useful

Strings are one of the spots where PHP habits carry over to TypeScript without a single type error, which is why they hurt. PHP to TypeScript is the book I wrote for PHP 8+ developers making that move: the sync-to-async shift, generics, discriminated unions, and the runtime differences like this one that the compiler won't flag.

It is one of five in The TypeScript Library. If you are coming from PHP, start with PHP to TypeScript and add The TypeScript Type System when the type system gets interesting. If TypeScript is going to production at work, TypeScript in Production covers the build and tooling side:

I also build Hermes IDE (GitHub), an IDE for developers who ship with Claude Code and other AI coding tools, so weigh the mention accordingly.

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

The TypeScript Library — the 5-book collection

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.