ADR-320: Text Is UTF-8 and Is Read Through a View That Names Its Unit — Bytes, Scalars or Graphemes; There Is No char

Status: Accepted; D10’s form and D11 added October 18, 2026; D12 added October 19, 2026; D13 added October 20, 2026. A hash a program can call is not decided here. Date: October 5, 2026 Answers: #453, what a program needs to work on text by hand: a search from a position, one element at a position, and what one character is. Related: ADR-016 D1 (text is valid UTF-8, checked where it enters); ADR-283 (a mapping, its views, what a Bytes offers); ADR-285 D1 (a count and a position are an i64), Part I 2.4 (a literal’s type comes from its use); ADR-287 (comptime); ADR-288 D11 (what the language offers is the specification’s decision); ADR-279 D8 (a value that is a word by its kind is copied).

1. Question

Text is UTF-8: a is one byte, ä two, € three. The element at position 5 can mean the byte at byte 5, the scalar starting there, or the fifth thing a reader sees, and those differ as soon as the text is not ASCII. A reader sees one ä whether it is stored as one scalar (U+00E4) or as a and a combining mark (U+0061 U+0308), and one family emoji that is five scalars.

The language had len() counting bytes, s[a..<b] cutting at byte positions, and char - one character, a Unicode scalar value - which a reader takes for what they see. None of it said its unit.

2. How others do it

  storage an element by number a character
Rust UTF-8 none on str; as_bytes()[i] char, a scalar; graphemes in a crate
Go bytes s[i] is a byte rune, a scalar
Swift UTF-8, ASCII flag, short strings inline none; String.Index, views utf8, unicodeScalars Character, a grapheme, checked in the compiler and segmented again by the runtime
Python Latin-1, UCS-2 or UCS-4 by content s[i], a scalar no type of its own
Java Latin-1 or UTF-16 by content charAt(i), a UTF-16 unit char, a UTF-16 unit

Rust and Go keep scalars in the compiler and graphemes in a library; Swift puts graphemes in both, and its compiler and runtime have disagreed about emoji sequences - on Apple’s systems the runtime is the operating system’s, so one binary segments differently on two versions.

3. Decisions

D1 — Text is UTF-8 everywhere, and a position in it is a byte position.

One representation. A file mapped into memory is text without a copy, and a name cut out of it is a view into it; a representation chosen by content, as Python’s and Java’s, would recode every input to get there.

D2 — There is no char. A scalar is scalar, and a byte is u8.

char means a byte in C, a UTF-16 unit in Java, a scalar in Rust and a grapheme in Swift, and every reader brings one of them. scalar is a Unicode scalar value - a code point that is not a surrogate (U+D800-DFFF), which valid UTF-8 never holds - and is a word, copied as a number is. It says what is counted and nothing about an encoding.

D3 — Text is read through a view that names its unit; there is no s.len() and no s[i].

s.bytes, s.scalars and s.graphemes. A number about text means nothing until its unit is said, and which unit a program means - ten bytes of a fixed field, ten scalars, ten things on a screen - is the program’s meaning, which no compiler can guess. Where no number is involved there is no unit to say: split, lines, trim, starts_with, contains, ==, find, a grammar and for need no view. s.is_empty() stays: empty is empty in every unit.

D4 — Brackets only where they cost nothing.

s.bytes[i] and s.bytes[a..<b] index and cut, and s.bytes.len() counts, in constant time. s.scalars and s.graphemes are sequences: take(n), skip(n), count(), for. Reaching the tenth grapheme walks nine; a bracket would hide that, and take(10) says it.

D5 — A position the text hands out cuts it without a view.

Replaced by ADR-374: a position is a text::Pos, a text is cut by before, after and between.

let sep = line.find(";") ?? return
let name = line[..<sep]
let rest = line[sep + 1..]

find’s result, a grammar’s match and such a position moved by the byte length of a known text are positions in that text; the compiler keeps where a position came from, as it keeps where a value came from for trust, and lets it cut the text it came from without a view. That is the compiler doing the work: the program never says a unit it did not choose. A number the program makes up needs a view (NK1214), and so does a position from one text used on another. A cut inside a scalar stops the program, in the language’s words.

D6 — find searches from a position, on text and on bytes.

Changed by ADR-374 D2: on text find and rfind give a text::Match? and from: is a text::Pos; on bytes this stands.

s.find(needle; from: i64) -> i64? on text, and b.find(byte: u8; from: i64) -> i64? on Bytes and on s.bytes. null is not there; a sentinel number would be one more thing to forget before a cut. On text from may be any byte position: UTF-8 is self-synchronising, so a match of a valid needle always starts on a scalar. A one-byte needle is a byte search of the kind memchr does, which is what makes a parser written by hand as fast as a grammar’s search.

D7 — A Bytes is read as a run of bytes.

b[i] -> u8, b[a..<b], b.len(), b.find(…), and b.text() to make text of it with the UTF-8 check. A buffer whose content cannot be read is not a buffer. Its checks are a list’s, and a proof drops them as it drops a list’s.

D8 — '…' is a u8 or a scalar, by its use; its content limits which.

'…' u8 scalar
one ASCII character: 'a', ';', '\n' yes yes
'\xNN' above \x7F yes no
one scalar that is not ASCII: 'ä' no yes

A literal no use types is a scalar. A surrogate is refused. Several scalars that normalise (NFC) to one are that one, with a warning (NK1213): an editor that stores é as e and a combining mark has written what its user meant as one character, and a scalar cannot hold two. The normalisation is the standard library’s own, and NFC composition does not change between Unicode versions (Unicode’s stability policy), so the meaning does not either. Anything else is refused (NK1212), with the text literal to write instead: "👍🏽".

D9 — Graphemes are the standard library’s, never the compiler’s.

s.graphemes segments by Unicode’s rules (UAX #29) and hands out each grapheme as a ref String, compared with a text literal. The rules change with Unicode versions, so they live in one place - std, released with the program - and the compiler’s meaning of a program never depends on them. A grapheme literal checked while the program builds follows from comptime once std’s segmentation is written in Nikaia: the check runs std’s code, not a copy.

D10 — == and a hash compare bytes; normalising is written.

a == b is true where the bytes are, and a hash agrees with it. A precomposed and a decomposed ä differ until the program writes s.normalized(text::Form::Nfc) (or Nfd, Nfkc, Nfkd); the form is always written, because NFKC also makes fi equal to fi. Equality that normalised would cost every comparison that is not ASCII.

The form is an argument, not an option after ;: an option has a default, and no form is one. It is a variant of text::Form written with its path, as every variant is, so a form can be held in a value and passed on.

D11 — s.bytes is a Bytes, a view wherever a view suffices.

s.bytes reads the text’s own bytes and is typed Bytes, the type fs::read and a socket hand out, so a function over bytes takes either and b[i], b[a..<b], len, find, text() and for are one set of reads (D7). As a String is a view wherever a view suffices (ADR-282 D2), so is this Bytes: it is the text’s buffer, borrowed, and costs no copy. A copy is made only where the program keeps it beyond the text it came from, as for a String.

D12 — A Bytes reads a word at a position, in the order it names.

b.u32_le(i) and b.u64_le(i) read the 4 or 8 bytes from i as an unsigned number, least significant byte first; b.u32_be(i) and b.u64_be(i) read them most significant first. Each reads what b[i..<i + n] reads and is checked as that cut is: outside, it stops the program, and a proof drops the check (D7). A signed i64 is the u64 read through bits_as_i64(), which keeps its bits (ADR-285 D42), and a signed 32-bit word the u32 through bits_as_i32(); as widens only where the value fits (Part I 2.2), so a u32 becomes an i64 with as. The widths are the language’s unsigned ones (Part I 2.2): there is no u16, so a two-byte field is two byte reads.

A file format, a protocol and a hash read words, and a program that writes eight byte reads, seven shifts and seven ors for one is asking the optimiser to find the load it meant. The order is in the name, because a word read in the machine’s own order gives another number on another machine. Reading a word is a read of bytes, not a construct for scanning: ADR-296 D12 keeps the grammar’s scans portable and word-at-a-time, and does not keep a program from writing its own.

D13 — No word is read past the end; a program that wants to pads the end itself.

let mut tail: Vec[u8] = Vec()
for b in data[cut..] { tail.push(b) }
for _ in 0..<8 { tail.push(0) }
let padded = Bytes(tail)

A word read stays what D12 makes it, checked as its cut, also at the end of a mapping. std offers no read that fills with zeros and guarantees no margin behind a mapping. A program whose loop reads whole words up to its last row copies the last bytes into a buffer of its own with zeros behind them and runs the same loop over that buffer.

Why. The copy is a few bytes once per input, and it is what the fastest programs in Rust and Java write by hand. A read that fills with zeros is four more methods beside the four of D12, each to be documented, for a case one loop per program has. A margin behind a mapping is a promise only some platforms keep without work, holds for a mapping and not for a Bytes that came from elsewhere, and would have to be carried in the type a function takes, against fs::Mapped being a Bytes. Rejected: zero-filled reads (u64_le_filled); a margin fs::map guarantees; reading past the end unchecked.

Decided October 20, 2026.

4. Rejected alternatives

5. What this does not decide

This site is open source. Improve this page.