Skip to main content
JavaScript (ECMAScript 2024) Standard: Unicode 17.0 / ECMA-262 Time: 8 min read Reviewed: September 2024

JavaScript Unicode Strings: Code Points & UTF-16

Master Unicode in JavaScript: UTF-16 surrogate pairs, String.prototype.codePointAt, ES2024 escapes, and Intl.Segmenter.

TL;DR — Direct Answer

Direct Technical Answer

JavaScript strings are sequences of 16-bit UTF-16 code units, not individual Unicode characters or user-perceived symbols. Supplementary characters outside the Basic Multilingual Plane (such as emojis and rare ideographs) require two UTF-16 code units (a surrogate pair), while composite graphemes require multiple code points.

💡
Production Rule: Never use string.length or string[i] to measure or slice user-visible text. Use String.prototype.codePointAt() for full code points, and Intl.Segmenter for user-perceived grapheme clusters.

The difference between UTF-16 code units, Unicode code points, and grapheme clusters in JavaScript

JAVASCRIPT unicode-distinction.js
const emoji = '😀';

// 1. String.prototype.length counts UTF-16 code units
console.log(emoji.length); // 2

// 2. String iterator ([...str]) counts Unicode code points
console.log([...emoji].length); // 1

// 3. Composite emoji with Zero-Width Joiner (ZWJ)
const family = '👨‍👦';
console.log(family.length); // 5 code units
console.log([...family].length); // 3 code points

// 4. Intl.Segmenter counts user-perceived grapheme clusters
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
console.log([...segmenter.segment(family)].length); // 1 user character
Program Output
2
1
5
3
1

Why This Happens: The ECMAScript String Model

To understand JavaScript string behavior, developers must understand the three distinct layers of Unicode representation in the browser and Node.js.

1. The UTF-16 Historical Legacy

When Brendan Eich designed JavaScript in 1995, Unicode was defined as a 16-bit fixed-width encoding (UCS-2) containing a maximum of 65,536 characters (the Basic Multilingual Plane, BMP). In 1996, Unicode expanded to 1,114,112 code points (Planes 0 through 16). Rather than breaking existing APIs, ECMAScript adopted UTF-16. In UTF-16, characters above U+FFFF are represented as two 16-bit code units called a surrogate pair: a high surrogate (\uD800..\uDBFF) followed by a low surrogate (\uDC00..\uDFFF).

2. Code Units vs Code Points

A code point is the abstract numerical value (U+0000..U+10FFFF). The grinning face emoji 😀 is code point U+1F600. In JavaScript memory, U+1F600 is encoded as the two UTF-16 code units 0xD83D and 0xDE00. Methods like .charCodeAt(0) and property .length inspect only individual 16-bit units, seeing two units instead of one character.

3. Code Points vs User-Perceived Grapheme Clusters

Even iterating by code point via Array.from() or for...of is insufficient for modern text. Composite emoji sequences (such as 👨‍👦) consist of multiple code points: Man (U+1F468) + ZWJ (U+200D) + Boy (U+1F466). Similarly, combining marks like "e" (U+0065) + combining acute accent (U+0301) form two code points that render as one visible glyph "é". Only Intl.Segmenter, conforming to Unicode UAX #29, accurately measures visual characters.

4. Lone Surrogates and Ill-Formed Strings

Because JavaScript strings are sequences of arbitrary 16-bit integers, they can contain lone surrogates (e.g. "\uD83D" without an accompanying low surrogate). ECMAScript permits lone surrogates in memory, but APIs requiring well-formed UTF-8 (such as TextEncoder, fetch(), or WebSockets) will replace lone surrogates with U+FFFD (Replacement Character) or throw an error.

Language & Runtime Execution Model

Target Language JavaScript
Modern Standard ECMAScript 2024
Minimum Floor ES2015 (ES6)
Segmenter Support Chrome 87+, Safari 14.1+, Firefox 125+, Node.js 16.0+, Bun 1.0+, Deno 1.8+
Engine Optimization

V8 uses an internal representation optimization: strings containing only Latin-1 characters (U+0000..U+00FF) are stored as 1-byte arrays (SeqOneByteString). Strings containing any character above U+00FF are promoted to 2-byte UTF-16 arrays (SeqTwoByteString).

Common Mistakes vs Production Patterns

Learn which patterns fail in production and the modern standards-compliant alternatives.

Counting User-Visible Characters

Character counter in UI forms, SMS limits, bio fields.
AVOID: ❌ String.prototype.length
const str = '👨‍💻';
console.log(str.length);
// Returns 5 (UTF-16 code units)
Why it fails: String.length counts 16-bit code units. Emojis with zero-width joiners and surrogate pairs report inflated counts.
RECOMMENDED: ✅ Intl.Segmenter (UAX #29)
const str = '👨‍💻';
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
console.log([...segmenter.segment(str)].length);
// Returns 1 (true user-perceived character)
Why it's better: Implements Unicode UAX #29 extended grapheme cluster boundaries, properly binding combining marks, modifiers, and ZWJ sequences.

Iterating Characters in a String

Parsing tokens, building custom string transformers.
AVOID: ❌ Index loop or str.split("")
const text = '🔥hello';
for (let i = 0; i < text.length; i++) {
  console.log(text[i]); // splits 🔥 into \uD83D and \uDD25!
}
const chars = text.split(''); // ['\uD83D', '\uDD25', 'h', 'e', 'l', 'l', 'o']
Why it fails: Direct bracket access text[i] and split("") operate on code units, tearing surrogate pairs in half and producing invalid lone surrogates.
RECOMMENDED: ✅ for...of or Array.from()
const text = '🔥hello';
for (const char of text) {
  console.log(char); // '🔥', 'h', 'e', 'l', 'l', 'o'
}
const chars = [...text]; // ['🔥', 'h', 'e', 'l', 'l', 'o']
Why it's better: The ECMAScript string iterator is code-point aware, automatically advancing past high/low surrogate pairs together.

Reading Numerical Code Points

Character encoding converters, cryptographic hashing, font metrics.
AVOID: ❌ charCodeAt(i)
const char = '😀';
console.log(char.charCodeAt(0).toString(16));
// Returns 'd83d' (High surrogate only!)
Why it fails: charCodeAt returns only the 16-bit number at that offset. For supplementary characters, it gives only half the surrogate pair.
RECOMMENDED: ✅ codePointAt(i)
const char = '😀';
console.log(char.codePointAt(0).toString(16));
// Returns '1f600' (True Unicode code point!)
Why it's better: codePointAt inspects the current and following code unit; if they form a valid surrogate pair, it returns the 21-bit scalar value.

Writing Unicode Escape Sequences

Source code literals, JSON payloads, regex definitions.
AVOID: ❌ Legacy Surrogate Pair Escapes
const fire = '\uD83D\uDD25'; // 🔥
// Prone to transposition, hard to read, manual math needed
Why it fails: Requires developers to manually compute high and low surrogate hexadecimal offsets.
RECOMMENDED: ✅ ES6 Curly-Brace Unicode Escapes
const fire = '\u{1F525}'; // 🔥
const smile = '\u{1F600}'; // 😀
Why it's better: Directly accepts any valid Unicode code point from \u{0} to \u{10FFFF} without surrogate calculation.

Edge Cases & Invariants Matrix

A diverse cross-character test matrix comparing character behavior across ASCII, combining marks, emojis, flags, and surrogate fragments.

Test Case Input Glyph Code Units Code Points Graphemes Category Technical Explanation
ASCII Latin Letter A 1 1 1 Basic Latin U+0041 fits into a single 7-bit byte, 16-bit code unit, and code point.
Precomposed Accent é 1 1 1 Latin-1 Supplement U+00E9 precomposed acute accent in BMP. 1 code unit, 1 code point, 1 grapheme.
Decomposed Accent 2 2 1 Combining Mark Base letter e (U+0065) + Combining Acute (U+0301). 2 code points render as 1 visual grapheme.
Currency Symbol 1 1 1 Currency Euro sign U+20AC located in BMP. 1 code unit, 1 code point, 1 grapheme.
Standard Emoji 😀 2 1 1 Supplementary Plane Grinning Face U+1F600 in Plane 1. Encoded as 2 UTF-16 code units (D83D DE00).
ZWJ Emoji Sequence 👨‍👦 5 3 1 Emoji Sequence Man (U+1F468) + ZWJ (U+200D) + Boy (U+1F466). 3 code points joined as 1 family glyph.
Rainbow Flag 🏳️‍🌈 6 4 1 Emoji Sequence White Flag (U+1F3F3) + Variation Selector 16 (U+FE0F) + ZWJ (U+200D) + Rainbow (U+1F308).
National Flag (Regional Indicators) 🇺🇳 4 2 1 Flag Sequence Regional Indicator U (U+1F1FA) + Regional Indicator N (U+1F1F3). 2 code points, 1 grapheme.
Lone High Surrogate 1 1 1 Surrogate Fragment Isolated high surrogate 0xD83D. Valid in ECMAScript memory, but ill-formed in UTF-8.

Core API Reference

Standard library methods, runtime signatures, and Unicode semantics.

str.codePointAt(index: number): number | undefined

Returns the Unicode code point value (integer) at the specified UTF-16 code unit index.

Unicode Semantics: If the index points to the start of a valid surrogate pair, returns the full 21-bit code point (U+0000..U+10FFFF). If it points to a low surrogate or solitary surrogate, returns only the surrogate code unit value.
⚠️ Important Caveats: The index parameter is still an offset in UTF-16 code units, not code points. To jump character by character, advance by 2 if codePoint >= 0x10000.
String.fromCodePoint(...codePoints: number[]): string

Creates a string from a sequence of 21-bit Unicode code points.

Unicode Semantics: Automatically calculates and emits high and low surrogate pairs for code points >= 0x10000.
⚠️ Important Caveats: Throws RangeError if a code point is negative, greater than 0x10FFFF, or NaN.
new Intl.Segmenter(locales?: string | string[], options?: { granularity: "grapheme" | "word" | "sentence" })

Provides locale-aware text segmentation, enabling reliable user-perceived grapheme cluster iteration.

Unicode Semantics: Conforms to Unicode Standard Annex #29 (UAX #29) default text segmentation algorithms.
⚠️ Important Caveats: Available in Chrome 87+, Safari 14.1+, Firefox 125+, Node.js 16.0+. Use polyfill for legacy browsers.
str.normalize(form?: "NFC" | "NFD" | "NFKC" | "NFKD"): string

Returns the Unicode Normalization Form of the calling string.

Unicode Semantics: Conforms to Unicode Standard Annex #15 (UAX #15). NFC (default) canonical decomposition followed by canonical composition.
⚠️ Important Caveats: Always normalize user input to NFC before comparing usernames or storing text in databases.
new RegExp(pattern, "v")

ECMAScript 2024 UnicodeSets regex flag enabling set operations and string properties.

Unicode Semantics: Supersedes the /u flag. Supports \p{Extended_Pictographic} and multi-character string sets like [\p{White_Space}--[\n\r]].
⚠️ Important Caveats: Supported in Chrome 112+, Safari 17+, Firefox 116+, Node.js 20+.
Interactive Tool

Interactive JavaScript String & Unicode Inspector

Test any string or emoji sequence in real time to inspect its UTF-16 code units, Unicode code points, and grapheme clusters in your browser.

UTF-16 Code Units (str.length) 11 16-bit storage words
Unicode Code Points ([...str]) 7 Individual characters
Grapheme Clusters (Intl.Segmenter) 1 User-perceived glyphs
UTF-8 Bytes 25 Serialized octets

Code Point Breakdown:

Standards & Source Provenance

All technical invariants, APIs, and behaviors in this guide are verified against official primary specifications.

Ecma International

ECMAScript 2024 Language Specification (ECMA-262 15th Edition)

Clause: §6.1.4 The String Type & §22.1 String Objects

Ecma International

ECMAScript Internationalization API (ECMA-402)

Clause: §18 Intl.Segmenter Objects

Unicode Consortium

Unicode Standard Annex #29: Unicode Text Segmentation

Clause: §3 Grapheme Cluster Boundaries

Unicode Consortium

Unicode Standard Annex #15: Unicode Normalization Forms

Clause: §1 Normalization Forms

MDN Web Docs

JavaScript String Reference & UTF-16 Representation

Clause: UTF-16 Characters, Unicode Code Points, and Grapheme Clusters