String And Bytes
A Ship String is a valid sequence of Unicode scalar values. It is not List(Char), not a random-access collection, and not JavaScript's UTF-16 string. Wire data is Bytes. UI graphemes and display width are library layers, not the core type.
Decisions
Byteis one UTF-8 code unit (0–255).'a'/'\n'/'\xNN'literals and patterns; inclusive ranges'0'..'9'. No arithmetic. ExplicitByte.to_int/Byte.of_int.Bytesis the immutable wire buffer. O(1)length/get. Parsers scanBytes/Bytes.Slicewithwhileand byte patterns.Charis one Unicode scalar value (U+0000–U+10FFFF except surrogates).c'é'is exactly one scalar; grapheme clusters with multiple scalars are rejected as Char literals.Stringis immutable, valid Unicode scalars. Equality, hashing, ordering, and literal patterns use the exact scalar sequence. Storage is not normalized.Owning
String/Bytesstay compact.String.Slice/Bytes.Sliceare explicit views that may retain backing storage. Converting a slice to an owning value is the copy/re-home boundary. Sharing is an optimization, not a cross-target allocation promise.No integer
String.length/peek/slice. Named units:byte_length,char_count(explicitly O(n)), scalar iteration, opaque cursors.Prefix matching is the inverse of concat:
"GET " ++ rest/b"GET " ++ rest. Rest is aSlice. Expression++concatenatesStringonly — there is noBytes ++ Bytesexpression; wire concat isBytes.Builder.+stays numeric.::is not list cons here (and is unused). Dynamic needles usestrip_prefix.Native storage is UTF-8. JavaScript may store a well-formed UTF-16 string privately. Source semantics do not expose host
.length, indexing, or.slice.Elixir/Erlang
<<>>bit-syntax is deferred:../questions/239-bytes-bit-syntax.md.Graphemes, NFC, and terminal width:
../questions/182-unicode-segmentation-and-normalization.md.
Layers
Byte / Bytes machine and wire
Char / String Unicode scalars and valid `String`
Grapheme view versioned UI segmentation (library)
Display width renderer/terminal-specific (library)
String may contain NUL, private-use, unassigned, and noncharacter scalars. Malformed UTF-8 belongs in Bytes. Decode at the String/Bytes edge:
let string = String.of_bytes(bytes); // exn :utf8(Int)
Literals
| Spelling | Type | Notes |
|---|---|---|
'a', '\n', '\x1f' | Byte | ASCII / byte escapes |
c'é', c'\u{1F980}' | Char | Exactly one scalar |
"GET " | String | Valid scalars; interpolation uses Display |
#"{"id":1}"#, ##"a "# b"## | String | Verbatim: no escapes, newlines kept; holes are $#{…} with the literal's fence |
b"GET ", b"\x89PNG" | Bytes | UTF-8 for ordinary chars; \xNN for arbitrary bytes; no interpolation |
Byte escapes: \n, \r, \t, \\, \', \", \0, \xNN. Char literals also accept \u{…} for a scalar. c'e\u{0301}' is two scalars and is a compile error.
Matching
Exact literals match the whole value. Byte and Char support inclusive ranges. String and Bytes are open domains and need _.
let classify_byte = function {
| '0'..'9' -> :ascii_digit
| '\r' | '\n' -> :line_break_byte
| _ -> :other_byte
};
let classify_spelling = function {
| "é" -> :precomposed
| "e\u{0301}" -> :decomposed
| _ -> :other
};
let classify_packet = function {
| b"\x89PNG\r\n\x1a\n" -> :png_signature
| b"PING" -> :ping
| _ -> :other
};
Prefix patterns invert concat. The left side is a constant sequence or a singleton Char/Byte (including ranges). The right side is a binder, _, or another ++ pattern. No suffix patterns. No runtime-variable prefix.
match input {
| b"GET " ++ rest -> Get(rest)
| b"POST " ++ rest -> Post(rest)
| b"GET " ++ "/" ++ path -> GetPath(path)
| _ -> Unknown(input)
}
match string {
| "Hello, " ++ name -> String.of_slice(name)
| c'a'..c'z' ++ _ | c'A'..c'Z' ++ _ -> :letter
| "" -> :empty
| _ -> :other
}
First match wins. "GET" ++ rest before "GETTING" ++ rest makes the longer arm dead — warn; do not longest-match; do not reorder arms. Prefix arms never exhaust String/Bytes.
String prefixes consume whole scalars. Bytes prefixes consume bytes and may split a scalar; decoding that rest as String still requires a UTF-8 boundary.
Slices And Cursors
let rest: Bytes.Slice = match input {
| b"GET " ++ rest -> rest
| _ -> Bytes.as_slice(input)
};
let owned = Bytes.of_slice(rest); // may copy; drops the parent reference
Views store owner + range, never interior pointers. Nested slices canonicalize to the original backing. A tiny slice of a huge buffer can retain that buffer until of_slice / String.of_slice copies.
Opaque String.Cursor values come from iteration and search. They are not integer scalar offsets.
Builders
String.Builder / Bytes.Builder are mutable scratch buffers. push / push_char / push_byte are amortized O(1) and return Unit. finish takes the accumulated buffer: native hands it off; JavaScript joins parts into a new host string / Uint8Array and clears the builder so a later push starts empty, matching native take. Uniqueness (Builder @ unique, finish consuming the value) is the modes story in 16-modes.md, not an enforced property of today's heap objects — do not let a builder escape the function that created it.
Do not accumulate with acc ++ ch in a loop.
Targets
| Fact | Native | JavaScript |
|---|---|---|
String contents | UTF-8 bytes | well-formed JS string (UTF-16) |
Bytes | no-scan byte payload | Uint8Array |
String ↔ Bytes | share backing when UTF-8 is known | copy (TextEncoder / fatal UTF-8 decode) |
Bytes.Slice | owner + range over the same ByteBacking | { $base, $start, $end } |
String.Slice / Cursor | owner + range (UTF-8 offsets) | same wrapper; indices are UTF-16 units internally |
| Observable semantics | exact scalars, UTF-8 decode, ++ prefixes | same |
Zero-copy is not portable source semantics. Values, validity, and match behavior are. The interpreter stores owning String/Bytes as views over a shared no-scan ByteBacking (Vec<u8> plus a “known UTF-8” flag).
Rejected
Grapheme-first
String/Char(Swift)Codepoint-indexed
String.length/peek/slice(Python)Byte-oriented
Stringthat may hold invalid UTF-8 (OCaml/Go)String ≡ Bytes(Elixir)Copy-on-write strings as the user model
+as string concat or prefix pattern::as string/bytes prefix (list cons of an element)Host JavaScript
.length/ indexing /.sliceas Ship unitsNormalizing storage or making
==canonical equivalence
See Also
syntax — literals,
++, byte/char patternscollections — builders,
Bytesas wireruntime — no-scan payloads