A4 · Part 3 · Advanced
Types & data
What happens once a number, a string, or a file gets big or exotic enough to need its own type.
06On this page
BigInt — exact integers, past 2^53
Every regular JS number is a 64-bit float, which means integers stop
being exactly representable past
Number.MAX_SAFE_INTEGER — 2^53 - 1.
BigInt is a genuinely separate type for arbitrary-size
integers with no such ceiling, spelled with a trailing n.
console.log(Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2); // what happens?
console.log(9007199254740991n + 1n === 9007199254740991n + 2n); // same numbers, as BigInt — what happens?
true, then false. Past the safe integer
limit, regular numbers genuinely can't tell
MAX_SAFE_INTEGER + 1 and + 2 apart — both
round to the same closest representable float. The n
suffix versions stay exact, because BigInt isn't a
float at all.
⚠ BigInt and Number don't mix
10n + 5 throws TypeError: Cannot mix BigInt and
other types — there's no implicit conversion between them in
either direction. Convert explicitly, one way or the other:
10n + BigInt(5) or Number(10n) + 5.
ArrayBuffer, TypedArrays, DataView
An ArrayBuffer is a fixed-length block of raw bytes —
nothing more, no way to read or write it directly. A
TypedArray (Int32Array, Uint8Array,
etc.) is a typed view onto that same memory, interpreting
its bytes as a specific numeric type. Multiple views can share one
buffer at once, and writing through any of them changes the
same underlying bytes every other view sees.
const buffer = new ArrayBuffer(4); // 4 raw bytes
const asInt32 = new Int32Array(buffer); // one view: "these 4 bytes are one 32-bit int"
asInt32[0] = 42;
const dv = new DataView(buffer); // a second, more manual view of the SAME bytes
console.log(dv.getInt32(0)); // what happens?
console.log(dv.getInt32(0, true)); // second argument: littleEndian — what happens?
704643072, then 42. Not a bug — a genuine
byte-order mismatch. TypedArrays use the platform's native byte
order (little-endian on essentially every real device today).
DataView.getInt32 defaults to big-endian unless
you explicitly pass true for its second argument. Same
4 bytes, same buffer, two different interpretations of what order
they represent a number in — exactly the kind of detail that matters
the moment you're parsing a binary file format or a network protocol
that specifies its own byte order.
TextEncoder, TextDecoder, and base64 the built-in way
const bytes = new TextEncoder().encode("café"); // a Uint8Array — always UTF-8, no other option
console.log(bytes.length); // what happens?
const text = new TextDecoder().decode(bytes);
console.log(text); // what happens?
5, then "café" — bytes.length
is 5, not 4, because "é" takes two bytes once it's UTF-8
encoded, a different count entirely from either the code-unit or
code-point counts covered next in this chapter.
TextEncoder/TextDecoder are the bridge every
binary API in this chapter eventually needs — fetch
bodies, WebSocket frames, and crypto.subtle all work in
bytes, not strings.
bytes.toBase64(); // "Y2Fmw6k=" — no more manual btoa/Uint8Array dance
Uint8Array.fromBase64("Y2Fmw6k="); // back to real bytes
Before these existed, converting bytes to base64 text meant a clumsy
detour through btoa and a manually-built string of
character codes — btoa only ever accepted text, not raw
bytes, and broke outright on anything outside Latin-1.
toBase64()/fromBase64() work directly on the
bytes themselves; encoding is no longer a text operation in disguise.
Blob, File, FileReader
const blob = new Blob(["hello world"], { type: "text/plain" });
blob.size; // 11 — bytes, not characters (matters once text isn't plain ASCII)
blob.type; // "text/plain"
// A File (from an <input type="file"> or a drop event) is a Blob with a name and a modified date
fileInput.addEventListener("change", (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = () => console.log(reader.result); // the fully-read contents
reader.readAsText(file); // or readAsArrayBuffer, readAsDataURL
});
// modern alternative — same result, promise-based, no event wiring
const text = await file.text();
const bytes = await file.arrayBuffer();
FileReader predates promises; a File
object itself now has .text()/.arrayBuffer()
methods that return promises directly — same underlying read, no
callback wiring needed in new code.
Unicode — code points vs code units
A JS string's .length counts UTF-16 code units,
not visible characters. Most characters fit in one 16-bit unit; a
large chunk of emoji and some rarer scripts need two units — a
surrogate pair — and .length counts both of them
as 2.
const emoji = "😀";
console.log(emoji.length); // what happens?
console.log([...emoji].length); // spreading iterates by CODE POINT, not code unit — what happens?
console.log(JSON.stringify(emoji[0])); // indexing still grabs one code UNIT — what happens?
2, then 1, then "\ud83d" — half
of a surrogate pair, not a valid character on its own.
emoji[0] silently cuts an emoji in half; spreading a
string (or for...of, or Array.from) walks
it by actual code point and never splits a surrogate pair. Slicing a string by
raw index — a search-result excerpt, a truncated preview — risks
exactly this cut, and it's an easy one to never notice until a
specific emoji or script breaks in production.
Code points still aren't what a reader calls a character. An emoji
like 👨👩👧 is five code points — three people glued together with
invisible zero-width joiners — so spreading it gives five pieces.
Counting what a person actually sees needs
Intl.Segmenter, which splits by grapheme:
const family = "👨👩👧";
family.length; // 8 — code units
[...family].length; // 5 — code points
const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" });
[...graphemes.segment(family)].length; // 1 — what the reader sees
Two strings can also look identical and still be different code points underneath: "é" can be one code point, or a plain "e" followed by a separate combining accent mark.
const composed = "caf\u00e9"; // é as ONE code point
const decomposed = "cafe\u0301"; // e + a combining accent
composed === decomposed; // false — yet both render as "café"
composed.length + " vs " + decomposed.length; // "4 vs 5"
composed.normalize("NFC") === decomposed.normalize("NFC"); // true — one canonical spelling
Rule Comparing user-typed text for equality
without
.normalize() first is a real, if rare, bug — two
strings can render pixel-identical and still fail
===, if one came from a source that encodes accents
differently.
Dry run: measuring the same string three different ways
| String | .length (UTF-16 code units) | [...str].length (code points) | Intl.Segmenter graphemes (what a reader sees) |
|---|---|---|---|
| "😀" | 2 | 1 | 1 |
| "👨👩👧" (family, joined by zero-width joiners) | 8 | 5 | 1 |
| "café" (one composed é) | 4 | 4 | 4 |
Three different numbers for the same three strings, and each answers a
different question: .length counts UTF-16 storage units
(what a naive slice would split on), spreading counts actual Unicode
code points, and Intl.Segmenter counts what a person would
actually call "one character." Only the third number matches human
intuition — which is exactly why slicing user-facing text by
.length or raw index risks cutting a character in half.
Intl — past basic formatting
["café", "cafe", "cafz"].sort(new Intl.Collator("en").compare);
// ["cafe", "café", "cafz"] — locale-aware ordering; a plain .sort() compares raw code
// points instead, which gets accented characters and non-Latin scripts sorted wrong
new Intl.RelativeTimeFormat("en").format(-1, "day"); // "1 day ago"
new Intl.RelativeTimeFormat("en").format(3, "hour"); // "in 3 hours"
All four Intl constructors from this and earlier
chapters — Collator, DateTimeFormat,
NumberFormat, and RelativeTimeFormat — take
the same first argument, a locale string, and are the built-in answer
to "format this correctly for the reader's language and region"
without hand-writing rules that differ by country.
Interview answer, one sentence
"BigInt gives exact integers beyond 2^53, typed arrays over an ArrayBuffer give raw binary data, and strings are UTF-16 code units, so length and indexing can split a character that takes more than one unit."
Before you move on
- Explain why BigInt and Number can't be mixed with
+, and how to convert between them explicitly. - Say why a TypedArray view and a DataView can disagree on the same 4 bytes, and which argument fixes it.
- Explain why
bytes.lengthfrom a TextEncoder differs from a string's own.lengthfor non-ASCII text. - Walk through why
"😀".lengthis 2 but[..."😀"].lengthis 1, and what a surrogate pair is. - Explain why composed "café" and decomposed "cafe" + combining accent fail
===but pass after.normalize("NFC").
Opens in the editor: write it, run it, and check it against real tests.
All 31 for this level →Finished reading?
0 of 41 chapters read in this series