Bounds on types
Decided 2026-11-02: Proposal D, a maximum only, written
T[max N]for everything, with strings as arrays ofchar(Strings aschar[]), so a bounded string ischar[max 64]and a string bound counts characters. A, B and C are rejected: their ranges proved easy to misread (see Misreadings). The decided design and its rollout are in the Design record; D's issues and resolutions are on its page.
The question. How does a schema say "at most 64 characters" or "1 to 100 items" as part of a type, so that a decoder can refuse an oversized value before allocating it? A validator can't do that, because it only runs once the value is fully decoded.
Where things stand:
- Arrays are
T[](any length) orT[N](exactlyN). Nothing in between. - Strings have no bound at all. The
StringBoundsvalidator checks one after decoding. string[]already means an array of strings, so a string's own bound can't be written asstring[..64]: that's an array of up to 64 strings.- Settling this syntax is on the 1.0 list.
What a bound is and how it works, whichever spelling is chosen, is in How bounds work.
Four proposals answer the question, written to the same shape. Tracked in core#98.
| Bounded string | In one line | |
|---|---|---|
| Proposal A | string(1..32) |
the bound in parentheses after string |
| Proposal B | string<1..32> |
the bound in angle brackets after string |
| Proposal C | @length = 1..32 on the field or alias |
the bound as an annotation, not part of the type's spelling |
| Proposal D | char[max 32], arrays Order[max 100] |
a maximum only, written with max; strings are char[]; minimums stay with validators |
The same schema, four ways
// A
type AccountName = string(1..32)
struct Customer {
tags: string(..32)[..10]
discount: union(string(4..16) u32)
}
protocol Orders {
function search(query: string(1..100), limit: u32) -> Order[..50];
}
// B
type AccountName = string<1..32>
struct Customer {
tags: string<..32>[..10]
discount: union(string<4..16> u32)
}
protocol Orders {
function search(query: string<1..100>, limit: u32) -> Order[..50];
}
// C
@length = 1..32
type AccountName = string
@length = ..32
type Tag = string // a bounded element needs an alias
@length = 4..16
type Coupon = string // likewise inside a union
@length = 1..100
type SearchQuery = string // and as a parameter
@items = ..50
type Results = Order[]
struct Customer {
@items = ..10
tags: Tag[]
discount: union(Coupon u32)
}
protocol Orders {
function search(query: SearchQuery, limit: u32) -> Results;
}
// D: maximums only; a minimum is a validator
type AccountName = char[max 32]
struct Customer {
tags: char[max 32][max 10]
discount: union(char[max 16] u32)
}
protocol Orders {
function search(query: char[max 100], limit: u32) -> Order[max 50];
}
Each proposal page has the full example: aliases, nested arrays, fixed arrays, unions, function parameters and returns, errors, and validators on top of a bound.
How they compare
A string(..n) |
B string<..n> |
C @length |
D [max n] |
|
|---|---|---|---|---|
Can be confused with string[] |
yes: a bracket after string, like the array suffix |
yes, as A | no: the type stays string |
no string(…) form; but string[max 64] (strings) and char[max 64] (a string) differ |
| Can be read as a slice or numbering | yes: T[1..12] |
yes | the range in @items = 1..12 too |
no: one number |
| "Exactly" against "at most" | T[3] against T[..3] |
as A | T[3] against @items = ..3 |
T[3] against T[max 3] |
| A minimum in the type | yes | yes | yes | no: a validator |
| Inside a union or a parameter list | parentheses inside parentheses | reads as a type | needs an alias | union(char[max 16] u32) |
| A bounded element in a bounded array | string(..32)[..10] |
string<..32>[..10] |
an alias for the element | char[max 32][max 10] |
Keeps <> free for generics |
yes | no | yes | yes |
| New grammar | a suffix on string |
a suffix on string |
none: an annotation | max inside [], and the char type |
| Settings can set a default bound | no | no | yes, like any annotation | no |
| Familiar from | SQL varchar(64) |
Rust and TypeScript generics | Protobuf field options, JSON Schema maxLength |
Protobuf validate's max_len |
Shared, whichever is chosen
T[N]stays exactlyN. An array's maximum isT[..10]under A and B,@itemsunder C, andT[max 10]under D.- Under A to C, ranges are inclusive at both ends.
..64means at most 64, the wayStringBounds(max_chars = 64)reads. D has no ranges. - A bound is part of the type's shape, so it lives in the frozen IR. Unbounded types stay byte-identical in the IR, so existing builds keep their hashes.
- Aliases carry their bound.
type AccountName = <bounded string>, and validators on a field of that type add rules on top. - Contradictions are refused at build: a minimum above the maximum, or a
bound no value can meet together with a validator (at most 10 characters and
StringBounds(min_chars = 20)). - Version comparison: any change to a bound is breaking. Tightening breaks old senders, loosening breaks old receivers.
- Generators decide the representation. A bounded string may become a
fixed-capacity, non-heap string. That's the generator's choice, not a schema
or
comline.tomlsetting.
Which types take a bound
How bounds work has the full model: each type and why, every position a bound can be written in, where a bound comes from (written, through an alias, a constant, a default), and what the build, version comparison, generated code, decoders and the language server each do with it. This section is the short version.
Decided 2026-11-02: size bounds only, on strings and dynamic arrays (rule 1
below); integer ranges stay with validators (IntRange). What follows is the
comparison that led there. core#98 names arrays and strings, and its reason,
refusing an oversized value before allocating, only covers types whose size
varies. A bound could mean two different things:
- A size bound: how big a value may be (characters, items). It protects decoders and can change the generated type (a fixed-capacity string).
- A range bound: which values are allowed (a
u8from 1 to 12). It protects nothing at decode time. It's a rule about the value, which is what validators are for today.
| Type | What a bound would limit | Kind | Case for | Case against |
|---|---|---|---|---|
T[] |
items | size | decoder safety; core#98's main reason | |
string |
characters | size | decoder safety | the unit question below |
| A bytes or map type, if added | bytes, entries | size | decoder safety | doesn't exist yet |
Integers u8…u64, s8…s64 |
value | range | month: u8(1..12) is concise; a generator could pick a narrower type, and a packed format could encode fewer bits |
it's what validators do; two ways to say one thing; no allocation to protect |
f32, f64 |
value | range | as integers | NaN and infinity sit outside every range; inclusive ends on floats are awkward |
bool, enums |
nothing | nothing to bound | ||
Structs, unions, () |
nothing obvious | an encoded-size limit for datagrams | belongs to the framing, not the type | |
optional T |
T's bound, when present |
|||
| An alias | its target's bound | carries the bound where the alias is used | may a use tighten it again? |
Three possible rules:
- Size only: types whose size varies (strings, arrays, and later bytes and maps). One meaning, "how big", and every bound protects a decoder. Value rules stay with validators.
- Size and range: any scalar too, with one syntax for two meanings. More concise, but a bound no longer always protects a decoder, and it overlaps validators.
- Size now, range later: ship rule 1, and keep the syntax able to take range bounds on scalars later without changing meaning.
The rule interacts with the syntax. Under A or B, u8(1..12) or u8<1..12>
would look the same as a string bound but mean a different kind of limit.
Under C, separate annotations (@length, @items, @range) keep each kind
apart.
Misreadings a spelling must avoid
Each of these came up when reading the examples above. A spelling that allows one needs the docs, hover and diagnostics to undo it every time.
| Written | Meant | Can be read as |
|---|---|---|
Order[1..100] |
each value holds 1 to 100 orders | a slice, elements 1 to 100 of a list, as xs[1..100] is in Rust and Python |
Order[1..100] |
each value holds 1 to 100 orders | numbering: the items numbered 1 to 100 |
Order[1..100] |
each value holds 1 to 100 orders | traffic: someone may send 1 to 100 orders over time, or must send them all at once |
u8(1..12) |
a u8 whose value is 1 to 12 |
a count, since u8[1..12] right beside it is 1 to 12 u8s |
u8(), string() |
a type | a call or a constant |
string(..64), string<..64> |
a string of at most 64 characters | something in the family of string[], the array |
What a bound always means, whatever the spelling:
- It describes one value. Every value of that field, parameter or return is checked on its own, in the message that carries it.
- It says nothing about traffic: how many messages are sent, how often, or whether items arrive at once or over time. Nothing accumulates across messages.
- "At most" is not "exactly".
T[N]is exactlyNin every value.
What a string bound counts
Decided 2026-11-02: characters, by making strings arrays of char
(Strings as char[]). What follows is the comparison that
led there.
The issue first proposed UTF-8 bytes. Checking what strings are across the project points to characters (Unicode scalar values) instead.
Strings are characters everywhere already. Every format and both
generators treat a string as a sequence of Unicode scalar values. Invalid UTF-8
and lone surrogates are refused, and StringBounds counts characters.
On the wire:
| Format | What's sent | Bytes per character |
|---|---|---|
packed@1, msgpack@1 |
the UTF-8 byte length, then the text | 1–4 |
json@1 |
escaped text, no length | 1–12 (😀 is 12 bytes for one emoji) |
In each language, a string's own "length" is:
| Unit | Languages |
|---|---|
| UTF-8 bytes | Rust, Go |
| UTF-16 code units | JavaScript/TypeScript, Java, C#, Dart |
| characters | Python |
| grapheme clusters (by default) | Swift |
No single unit matches the wire, the formats and the languages at once, so the bound is defined on the value, and each format derives its own early check:
| Characters | UTF-8 bytes | |
|---|---|---|
| Same meaning in every format and language | yes | no: exact only for UTF-8 formats and UTF-8 languages |
Agrees with StringBounds |
yes | no: two units for one idea |
Refuse before allocating, packed@1 / msgpack@1 |
yes: a length prefix over 4 × N | yes: a prefix over N |
| Exact check while decoding | free, during the strict UTF-8 check decoders already run | free |
json@1 |
after unescaping; the frame limit caps allocation | the same |
| Cheap for a sender in a UTF-16 language | yes: counted from UTF-16 | needs an encoding pass |
| Fixes the encoded size | no: up to 4 × N bytes | yes, for UTF-8 formats |
UTF-16 units and grapheme clusters are not candidates: they depend on the language or on the Unicode version.
If an exact byte budget is ever needed (a datagram's size, say), it belongs to the format or framing as a frame or packet limit, not to the string's type.
A char type (one Unicode scalar value) is a separate question. It can't stand
in for bounded strings: char[..64] would travel as an array of 64 values,
encoded differently from string.
Open points
- Does a bracket after
stringread as "a different kind of string" or as "a collection of strings"?string()andstring<>sit right next tostring[], which is an array. Unless the documentation, the language server (hover, completion, diagnostics) and error messages all explain bounds well,string(..64)can look like it's in the same family asstring[]. This argues for C, for a spelling with a keyword, or for teaching bounds as one concept across strings and arrays. - ~~What a string bound counts~~: decided, characters, with strings as
char[](Strings aschar[]). - ~~Constants as bounds~~: decided, allowed (issue 3).
- An exact length:
8..8only, or a shorthand. A shorthand likestring(8)reads too much like the fixed arrayf32[3]. - ~~Which types take a bound~~: decided, size bounds only: maximums on strings and dynamic arrays. See Which types take a bound.
- Under D, the cost of minimums as validators: an extra pass per string, and checks that run only when the receiver opts in (weighed here). Unmeasured.
- ~~Under D, a string's own maximum~~: decided,
char[max 64], replacing the earlierstring(max 64)(why). - ~~Under D, minimum validators std would need~~: shipped in std,
NonEmpty,MinChars,MinItemsandIntRange(the guide).