Topics

Jump to a topic.

Search questions

Find a question by title, tag, or summary.

What are lone surrogates, and what do isWellFormed and toWellFormed fix?

Questions in this chapter (5)
  • Draft
  • hard
  • Askedrarely
  • Verified October 2026

Answer

A JavaScript string is any sequence of 16-bit values. The spec places no restriction on them, so a string can hold a lone surrogate: a leading surrogate (0xD800–0xDBFF) not followed by a trailing surrogate, or a trailing surrogate (0xDC00–0xDFFF) not preceded by a leading one. Such a string is ill-formed UTF-16 and does not represent valid Unicode text.

They usually come from code that treats strings as units: slice or substring cutting a pair in half, split("") followed by manipulation, or a truncating charAt loop.

Two methods handle them (widely available in browsers since October 2023, per MDN):

  • str.isWellFormed() returns false if the string contains any lone surrogate.
  • str.toWellFormed() returns a new string with every lone surrogate replaced by U+FFFD (�). It returns a copy even when nothing needed fixing.

Why it matters: APIs that need valid Unicode reject ill-formed input. encodeURI and encodeURIComponent throw a URIError. Encoding to UTF-8 has no representation for a lone surrogate, so TextEncoder substitutes U+FFFD. JSON.stringify escapes lone surrogates ("\ud800") so its output stays valid.

Use isWellFormed() to validate or reject input, and toWellFormed() to sanitize it before passing it to such APIs. The fix is lossy: the original surrogate value is gone. Better still, avoid creating them: iterate by code point or grapheme instead of slicing by unit.

Example

const cut = "a😀".slice(0, 2); // keeps only the leading surrogate
console.log(cut.length, cut.isWellFormed()); // → 2 false

try {
  encodeURIComponent(cut);
} catch (error) {
  console.log(error.name); // → URIError
}

const fixed = cut.toWellFormed();
console.log(fixed === "a�", fixed.length); // → true 2
console.log(encodeURIComponent(fixed)); // → a%EF%BF%BD

console.log("\uDE04\uD83D".isWellFormed()); // → false (wrong order)
console.log("😄".isWellFormed()); // → true

Pitfalls

  • Believing a pair in the wrong order is valid. A trailing surrogate followed by a leading one is two lone surrogates.
  • Expecting toWellFormed() to repair the text. It substitutes U+FFFD, so the data is lost; it does not rejoin broken pairs.
  • Calling it on every string "just in case" and hiding the real bug (slicing by code unit) that produced the broken value.
  • Assuming every consumer throws. TextEncoder substitutes U+FFFD and JSON.stringify escapes, so a lone surrogate can pass through silently and change the data.

Follow-ups

Does toWellFormed() mutate the string?
No. Strings are immutable. It returns a new string, which is a copy even for well-formed input.
What does JSON.stringify do with a lone surrogate?
Engines that implement the well-formed JSON.stringify change write it as an escape such as "\ud800" instead of the raw unit, so the output is valid Unicode. JSON.parse reads the escape back as the same lone surrogate.
Does [...str] drop or fix lone surrogates?
Neither. The string iterator yields a lone surrogate as its own one-unit string, which stays ill-formed.

Exercises

Which are well-formed?

const samples = ["ab", "\uD83D", "😀", "\uDE00\uD83D", "\uD83Dx"];
console.log(samples.map((s) => s.isWellFormed()));

Sanitize before encoding

const input = "q=\uDC00";
console.log(encodeURIComponent(input.toWellFormed()));