Types & data
What happens once a number, a string, or a file gets big or exotic enough to need its own type.
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.
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.
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 one. 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.
"café".normalize("NFC").length === "café".normalize("NFC").length;
// true — but two strings that VISUALLY look identical can be genuinely unequal:
// "é" can be one single code point, OR "e" + a separate combining accent mark.
// .normalize() converts both spellings to one canonical form before comparing.
.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.
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 three 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.
Opens in the editor — write it, run it, and check it against real tests.