Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Positive and NonNegative integer domain types

Status: Implemented. The language types are recorded in B95 and the std migration in B95 std limits. The numbers reference defines the types and the std conventions the parameter rule. What shipped differs from the tables below as listed in As shipped; Bounded now has its own implementation under B96, and a compile-time check of fixed ceilings is issue #686.

Problem

A std parameter with a fixed domain, such as a byte limit that must not be negative or a buffer capacity that must be at least one, is checked inside the function and reported as an Err:

pub def parse(text: &str, max_bytes: i64) -> Result[Url, UrlError]   # InvalidLimit for max_bytes < 0
pub def buffered_reader[R](inner: R, capacity: i64) -> Result[BufReader[R], StreamError]   # OutOfRange for capacity <= 0

The failure is a caller bug that the caller could rule out, but every call pays for it: a ?, an error variant to match, and a check on each call. Almost every call passes a literal or a default, so the check can never fail at those sites. The consistency rules (rule 5.3) already say “where a type can state the domain, the parameter uses it”, and NonZero[i64] does this for range and slice steps. No type states “at least zero” or “at least one”.

The migration list covers the public std parameters and Limits fields whose lower bound is 0 or 1 and is checked at run time today.

Summary

QuestionDecision
New typesPositive[T] (value > 0) and NonNegative[T] (value >= 0)
TSigned integer types only: i8, i16, i32, i64
ConstructionInteger literal where one is expected (checked at compile time), or try_from
try_from errorRangeError.OutOfRange, as NonZero.try_from and T.try_from
Reading back.get() returns T; no implicit conversion to T
Arithmetic+ and * keep the type; - returns T; other operators are compile errors
Relation to NonZeroSeparate nominal types; NonZero.from(p) and NonNegative.from(p) widen a Positive
Copy, format, hashAs NonZero: copies, prints as its integer, valid dictionary key
len()Unchanged, returns i64; no implicit conversion
SizesStay i64 (integer types); only the wrapper is new

The types

Element type

Positive[T] and NonNegative[T] take a signed integer type. An unsigned type is a compile error that names the existing spelling, because the domain already has one:

Positive[u64]      # error: every nonzero `u64` is positive; use `NonZero[u64]`
NonNegative[u32]   # error: every `u32` is non-negative; use `u32`

One domain has one spelling. Std quantities are i64 by convention, so Positive[i64] and NonNegative[i64] are the forms std uses; the narrower signed widths exist for user data and cost nothing extra, since the implementation is generic over T.

Positive and NonNegative are builtin names and cannot be redefined, like NonZero.

Construction

The rules are those of NonZero, with a different domain check.

def per_page(items: i64, size: Positive[i64]) -> i64:
    items // size.get()

def main() -> Result[(), IoError | RangeError]:
    print(per_page(100, 20))?          # literal converts at compile time
    print(per_page(100, 0))?           # error: `0` is not a `Positive[i64]`
    configured = 25
    size = Positive.try_from(configured)?
    print(per_page(100, size))?
    limit: NonNegative[i64] = 0        # 0 is in the domain
    bad: NonNegative[i64] = -1         # error: `-1` is not a `NonNegative[i64]`
    Ok(())
  • An unsuffixed integer literal, possibly negated, converts where a Positive[T] or NonNegative[T] is expected: an argument, an annotated binding, a return value, a class constructor argument, a field assignment, a default parameter value, or inside Some(...). A literal outside the domain is a compile error. These are the positions where a NonZero literal converts today; a probe with the current compiler confirmed defaults, returns, constructor arguments, field assignment and Some(3) for an Option[NonZero[i64]] parameter.
  • Positive.try_from(value) returns Result[Positive[T], RangeError], with T taken from value; Positive[T].try_from(value) names T and requires a T. NonNegative.try_from is the same. Out of domain is Err(RangeError.OutOfRange).
  • The constructor is private: Positive(3) is a compile error that names the two conversions.

RangeError is the error every integer narrowing already uses (checked conversion), so one error type covers i32.try_from, NonZero.try_from, Positive.try_from and NonNegative.try_from, and a function that does several returns Result[_, RangeError] without a union. It has one variant, so an exhaustive match needs one arm.

Reading back and mixing with T

.get() returns the T. There is no implicit conversion to T: an operation that mixes Positive[i64] with i64 is a type error whose hint shows .get() or try_from, as for NonZero. Implicit unwrapping would let a domain value silently become an i64 and come back only through try_from, which hides where the check happens.

Arithmetic and comparison

Operands must have the same type. An integer literal beside a domain operand takes that operand’s type, so n + 1 is a NonNegative[i64] and size * 0 is a compile error for a Positive.

OperationPositive[T]NonNegative[T]Why
a + b, a += bPositive[T]NonNegative[T]Sum stays in the domain; overflow traps
a * b, a *= bPositive[T]NonNegative[T]Product stays in the domain; overflow traps
a - bTTCan leave the domain
unary +Positive[T]NonNegative[T]Identity
unary -TTLeaves the domain
//, %, bitwise, shiftsCompile error, suggests .get()SameAs NonZero; a domain divisor is a later addition (decision 4)
==, !=, <, <=, >, >=boolboolSame-type operands or a literal

Overflow in + and * traps, or is an Err in a checked: block, so a sum cannot wrap to a negative value. A comparison with a literal outside the domain, such as size > 0 for a Positive, is a compile error, as step == 0 is for NonZero: the literal is not a Positive. n >= 0 for a NonNegative compiles, since 0 is in the domain.

Relation to NonZero

A Positive[T] is nonzero, but it is not a NonZero[T]: Plenty has no subtyping, and each is a nominal @newtype class. Code that holds a Positive and needs a NonZero (a range step) or a NonNegative (a limit) widens it explicitly:

def chunks(total: i64, size: Positive[i64]) -> Result[(), IoError]:
    for start in range(0, total, NonZero.from(size)):
        print(start)?
    Ok(())

NonZero.from(p) and NonNegative.from(p) take a Positive[T], cannot fail, allocate nothing and compile to nothing. A from that cannot fail beside a try_from that can matches the pair the reader already sees in T.try_from. Widening is explicit (decision 3); see Alternatives.

There is no conversion from NonNegative to NonZero or Positive other than try_from(n.get()), since zero is in its domain.

Copy, format, hash and layout

As NonZero[T]:

  • the value has T’s representation and allocates nothing;
  • it copies, is a valid dictionary key and set element, and is usable as a field, collection element and @copy component;
  • print, f-strings and debug show the integer, and format specs apply as to T.

Both are declared in the builtin arithmetic-errors module beside NonZero (src/frontend/modules.rs), as private @newtype @copy classes with a get() method.

len() and sizes

len() returns i64 and stays so. Making it return NonNegative[i64] would break i < len(xs) with i: i64, range(len(xs)), and len(xs) - 1 used as an index, which are the most common uses of len(). A rule that converts a len() call where a NonNegative[i64] is expected, like a literal, is possible but is not needed now:

  • Allocation sizes are out of scope. list.with_capacity, Bytes.reserve, resize, LruCache(capacity) and std.actor.stage report a negative size as CapacityOverflow (rule 5.1, local PR #804), so with_capacity(len(x)) is unchanged.
  • No Positive parameter can take len(), which can be zero.
  • A search of stdlib/, the tutorial, the reference, examples/ and tests/ found two calls that pass a length expression to a migrated parameter, both inside std with arithmetic (text.plenty builder(self.max_bytes - used_bytes + ...) and regex.plenty self.scan(2 * len(self._names)), which is private). A len()-only rule would cover neither.

Where a length must become a limit, the call is explicit:

mut out = std.text.builder(NonNegative.try_from(2 * len(text))?)?

Inside std, public functions call .get() once and pass i64 to private helpers, so internal arithmetic is unchanged. A len() conversion rule is not part of this design (decision 5).

Migration list

A parameter migrates when its whole static domain is >= 0 or >= 1. The lists below are the public std and builtin parameters with that lower bound that return Err or trap today. P is Positive[i64] and N is NonNegative[i64]; each parameter is i64 today unless stated. Result types are unchanged except where noted, and follow PR #804 where it changes them.

Group A: the invalid-argument failure leaves the error type

Function or classParameters afterRemoved today
std.url.parse(text, max_bytes)max_bytes: NUrlError.InvalidLimit (its only use)
std.db.kv.Store.scan(..., max_rows, ...), scan_all(..., max_rows, ...)max_rows: NKvError.InvalidLimit (its only uses)
std.markdown.Limitsmax_input, max_depth, max_output: NMarkdownFault.InvalidLimits
std.http.body.FormLimitsmax_bytes, max_fields: NBodyError.InvalidLimit, with the two rows below
std.http.body.MultipartLimitsmax_parts, max_head, max_field, max_file, max_body: NSame
std.http.body.read_json(exchange, limits)via std.json.Limits.max_input: NSame
std.actor.net.connection(stream, limits)std.actor.net.Limits input, output: PBytesError.OutOfRange; the result can narrow to AllocError
builtin channel[T](capacity)capacity: P (from u64)ChannelError.InvalidCapacity
builtin ThreadPoolExecutor(max_workers, queue_capacity)both P (from u64)PoolError.InvalidSize
.par(&pool, chunks=N)chunks: PThe par chunks must be positive trap

The two builtins move from u64 to Positive[i64] rather than to NonZero[u64], which removes their listed deviation from the integer-type convention in the same break (decision 2, issue #665).

Group B: the call loses this failure; the error type keeps the variant for other causes

Function or classParameters afterVariant kept for
std.io.buffered_reader(inner, capacity), buffered_writer(inner, capacity)capacity: PStreamError.OutOfRange: stream ranges
std.io.typed.buffered_reader(inner, capacity)capacity: PSame
std.io.BufReader.read_until(delimiter, out, limit)limit: PSame
std.io.read_up_to(reader, out, limit)limit: NSame
std.process.Child.communicate(input, limit), output(limit)limit: NSame
std.csv.reader(inner, dialect, capacity)capacity: PCsvError.OutOfRange: field indexes
std.log.text_sink(writer, max_line)max_line: PLogError.InvalidSpec: specs
std.log.memory_sink(capacity)capacity: NSame
std.fmt.Formatter.floating(value, width), integer(value, width), unsigned(value, base, width)width: NFormatError.InvalidSpec: base, specs
std.text.builder(max_bytes), join(..., max_bytes)max_bytes: NTextError.InvalidLimit: wrapper geometry, below
std.text.hard_wrap(text, width, max_bytes)width: P, max_bytes: NSame
std.text.wrap, fill, shorten, text_wrapperwidth: P, max_bytes, max_input_bytes: NSame
std.text.dedent, indent, indent_withmax_bytes, max_input_bytes: NSame
std.text.TextWrapper fieldswidth: P, tabsize: N, max_lines: Option[P], max_bytes, max_input_bytes: NAn indent wider than width, a placeholder too long
std.fs.copy_from(..., max_bytes, ...), copy_file(..., max_bytes, ...)max_bytes: NFsError.InvalidInput: names, ceilings
std.fs.glob(parent, pattern, max_entries, max_matches, max_steps)all three NSame: pattern length
std.system.System.block_devices, disk_iomax_devices: NSystemError.InvalidInput: max_bytes, pid
std.system.System.process_ids(max_entries)max_entries: NSame
std.archive.zip_reader(inner, max_entries)max_entries: NArchiveError.InvalidInput: names, modes
std.compress.inflater(format, limit), decompress(format, data, limit)limit: NCompressError.OutOfRange: crc32 seed
std.http.encode_chunk_size(length)length: NStreamError.OutOfRange in HttpError
std.http.typed.fixed_body(inner, length)length: NFramingError.OutOfRange: read ranges
std.http.Limits, decoder and clientmax_headers, max_body: NHttpError.InvalidLimit: max_line, max_head >= 2
std.http.server limitsmax_line, max_head, max_headers: P, max_body: NOther Config fields with other bounds
std.http.websocket.Configmax_frame, max_message: PWebSocketError.InvalidConfig: one other mapping
std.actor.http.server configmax_requests: P, limits.max_line, max_head, max_headers: P, max_body: NServerError.InvalidConfig: capacity, heap, buffer minimums
std.actor.net.listener(..., capacity, heap, ...)capacity, heap: PNetError: transport failures
std.actor.net.Connection.reserve(wanted)wanted: NNetError: closed connection
std.actor.tls.over(net, session, input)input: PTlsError.Net: transport failures
std.image.Limitsmax_width, max_height, max_bytes: NImageError.OutOfRange: dimensions
std.stat.quantiles(values, n)n: PStatError.Domain: float parameters
std.math.factorial(n)n: NMathError.Domain; Overflow above 20 stays

Group C: the lower bound is typed; a fixed ceiling remains

Function or classParameters afterCeiling
std.fmt.formatter, stderr_formatter, string_formattermax_bytes: N, max_depth: Nmax_depth <= 64; also scratch.len() >= 32
std.json.Limits, std.toml.Limitsmax_depth, max_input, max_output: Nmax_depth ceiling
std.xml.Limitsmax_token: P, others Nmax_depth <= 1024
std.regex.Limitsall seven Nmax_nesting ceiling
std.template.Limitsmax_context_nodes, max_depth: P, others Nmax_depth <= 64
std.unicode.nfd, nfc, nfkd, nfkcmax_scalars: Ni64.MAX // 4
std.sketch.quantiles(capacity), quantiles_from_bytes(data, max_capacity)P1048576
std.sketch.frequent_items(capacity), frequent_items_from_bytes(data, max_capacity)P65536
std.sketch.xor.build, from_bytesmax_keys: Nkey_limit
std.sketch.cuckoo.cuckoo, from_bytesmax_buckets: Pbucket_limit; initial_buckets must be a power of two
std.fs.TempDir.cleanup_tree, std.fs.remove_treemax_entries, max_depth: Pmax_depth <= 64
std.fs.temporary_dir(parent, prefix, attempts)attempts: P1024
std.tempfile.directory(..., max_entries, max_depth)max_entries, max_depth: Pmax_depth <= 64
std.jwt decoding max_sizePmax_token_size
std.crypto.hkdf_sha256(..., size)size: N8160
std.image.new, from_pixelswidth, height: P2147483647
std.actor.Context.spawn, spawn_isolated, Stage.startlimit: Pmax_heap_limit
std.system.System.process(pid, max_bytes) and the max_bytes of load_average, uptime, cpu_count, memorymax_bytes: P!= i64.MAX; pid != 0

A ceiling keeps an Err variant, so the migration removes the ? from these calls only if the ceiling stops being an Err. Rule 5.3 says an argument outside its documented domain traps and a constant outside it is a compile error; under that rule these ceilings trap, as the run-time domains in PR #804 do, and the variants leave the error types. This design adopts that rule (decision 1): a ceiling traps, and a literal above a constant ceiling is a compile error.

Not migrated

ParameterReason
with_capacity, Bytes.reserve/resize, Heap.reserve, graph reserve, LruCache(capacity), std.actor.stage counts, std.secrets.token_bytes/token_hex/token_urlsafeAllocation sizes: CapacityOverflow (rule 5.1, PR #804)
str.repeat(count), slice boundsClamp (rule 5.2)
std.ready.selector(capacity)Clamps to 1..most_events today; a deviation to fix separately
regex replace limitA negative value means unlimited; a sentinel, not a domain (Option would state it)
Bytes range methods, std.actor.net.Connection.consume/commit/forward, Context.restart, std.image.Image.crop, std.random.Rng.index/sample_indices, csv/sqlite/watch indexesThe upper bound depends on the receiver or another argument; one trap covers both ends (PR #804)
std.http max_line, max_head (>= 2), std.sketch.hyperloglog precision (4..18), encode_png level (0..9), actor server capacity, heap_limit, buffer minimumsLower bound other than 0 or 1; needs Bounded
std.compress.crc32 seed (0..u32 max), archive modes (0..4095)The parameter should be a narrower type (u32, u16); separate fix
std.regex.find_all/split max_matches/max_pieces, std.http.cookie.cookies limit, std.db max_lengthNot checked today; their negative behaviour needs its own review first

As shipped

Differences from the migration list, recorded after the migration (2026-11-02):

  • Shared HTTP limits. std.http.Limits serves the client, decoder and both servers, and allows max_headers = 0. Its max_headers and max_body are NonNegative[i64]; max_line and max_head (>= 2) stay i64 with their InvalidLimit check, so the server rows’ P typing waits for Bounded (B96).
  • Removed beyond the tables. std.actor.StartError.Limit, SystemError.InvalidInput, CsvError.OutOfRange (field indexes already trapped after PR #804) and BytesError.OutOfRange (its last cause was the actor networking limits).
  • Narrowed results. std.actor.net.connection returns AllocError, so Listener.accept adds AllocError to NetError. listener keeps Result[Listener, NetError].
  • Also migrated. std.actor.tls server and client input sizes (they forward to over), std.system block_devices/disk_io max_bytes, std.compress.decompressor(limit), std.fmt.to_string/to_debug and std.report.to_string limits.
  • Ceilings that trap. As decision 1, plus std.actor.net.listener heap above max_heap_limit. quantiles_from_bytes and frequent_items_from_bytes had no ceiling and gained none. The compile-time rejection of a literal or module constant above a ceiling is implemented with std-only declaration metadata (issue #686); dynamic inputs retain the runtime guards.
  • Constants. A module constant could not have a domain type, so std passed constant limits through private getters such as def max_url() -> NonNegative[i64]:. Issue #685 later added domain constants (const max_url: NonNegative[i64] = 8192) and replaced the getters.
  • Internal lengths. Two std callers of encode_chunk_size convert a len() result with NonNegative.try_from(n).unwrap(), which cannot fail (decision 5).

Example

Before:

def lines(path: &Path) -> Result[list[str], AppError]:
    file = open_file(path)?
    mut reader = std.io.buffered_reader(file, 65536).map_error(AppError.Stream)?
    mut out = std.text.builder(1048576).map_error(AppError.Text)?
    ...

After, with literals: the two map_error adapters for impossible failures go, and the builder’s remaining failure is allocation.

def lines(path: &Path) -> Result[list[str], AppError]:
    file = open_file(path)?
    mut reader = std.io.buffered_reader(file, 65536)
    mut out = std.text.builder(1048576)?
    ...

buffered_reader can still fail to allocate its buffer, so it keeps a Result; whether that result narrows to AllocError follows rule 5. With a configured value, the check moves to where the value is read:

def configured(settings: &Settings) -> Result[Positive[i64], AppError]:
    Positive.try_from(settings.buffer_size).map_error(AppError.Config)

Alternatives

  • Bounded[T, lo, hi]. States every domain in the tables, including ceilings and >= 2. Initially deferred in favor of two fixed domains; bounded integers now implement this as a separate type using B83 value parameters. The arithmetic rules would depend on the bounds: Bounded[i64, 1, 64] + 1 has no simple result type. Bounded therefore uses explicit .get() for arithmetic; the two fixed domains keep their existing operations.
  • Unsigned parameters. max_bytes: u64 rules out negatives without a new type, but breaks the i64 convention for sizes, forces casts at every len() and index, and u64 cannot state >= 1. Rejected.
  • Plain std classes with try_from only. No compiler change, but no literal conversion, so every call writes Positive.try_from(64)?, which is the ? this proposal removes. Rejected.
  • Implicit widening to NonZero and NonNegative. Saves NonZero.from at range steps. It adds a third way for a domain value to appear where one is expected (literal, try_from, widening), and is subtyping in all but name. Rejected (decision 3).
  • Implicit unwrapping to T. size + offset with offset: i64 would work, but the domain value disappears silently and comes back only through try_from. Rejected, as for NonZero.
  • Keep run-time checks, document them. Leaves the ? and the variant on every call. Rejected by rule 5.

Cost and compatibility

  • Compiler. NonZero is 247 lines in src/frontend/nonzero.rs, plus hooks in operators, f-strings, metadata and diagnostics (116 mentions in 22 files under src/). Generalizing it to a domain enum (NonZero, Positive, NonNegative) with a per-domain literal check, operator table and diagnostic is the expected shape, not three copies. The signed-only bound is a new check: IntType admits all eight integer types. Codegen needs no change, since the representation is T.
  • Breaking changes. Every migrated parameter rejects a plain i64 variable; literals and defaults keep compiling. A call passing a variable adds Positive.try_from(x)? or NonNegative.try_from(x)?, and the diagnostic shows that form. Code that matches a removed variant (UrlError.InvalidLimit, KvError.InvalidLimit, MarkdownFault.InvalidLimits, BodyError.InvalidLimit, ChannelError.InvalidCapacity, PoolError.InvalidSize) gets an unknown variant error. Code that constructs or mutates a Limits with a variable converts it; code that reads a field calls .get().
  • Std internals. Public functions call .get() and pass i64 to private helpers, so most of the change is at signatures and in valid functions, which shrink or disappear.
  • Docs. The numbers reference gains the two types next to NonZero; each module page in the tables updates its signature, error and limit rows; conventions.md “Invalid arguments” and “Limits” state that a fixed lower bound is a type; the cheat sheet, tutorial lessons that pass computed limits, the plenty skill and signatures.md follow.
  • Performance. No change to representation or allocation; each migrated call drops one comparison and branch.

Phasing

  1. Language: Positive[T] and NonNegative[T], literal conversion, try_from, .get(), operators, NonZero.from/NonNegative.from, formatting and hashing, with the numbers reference and cheat sheet.
  2. Group A, which removes variants and needs the largest doc change, with the builtins channel, ThreadPoolExecutor and par(chunks).
  3. Group B, module by module; each module’s page and tests change together.
  4. Group C, whose ceilings change from Err to traps in the same step, so each module changes once.
  5. Delivered in issue #664: // and % with a plain integer dividend and a NonZero or Positive divisor, without the zero check. See nonzero arithmetic.

PR #804 merges first; this work changes the same std error types.

Decisions

Decided 2026-11-01, on review of local PR #812.

  1. Group C ceilings trap under rule 5.3. A literal above a constant ceiling is a compile error, and the ceiling’s Err variant leaves the error type.
  2. channel and ThreadPoolExecutor take Positive[i64], not NonZero[u64], which removes their u64 deviation from the integer-type convention. Tracked as issue #665, in phase 2.
  3. Widening is explicit: NonZero.from(p) and NonNegative.from(p). There is no implicit widening.
  4. Division by a domain divisor is delivered (issue #664). T // NonZero[T], T % NonZero[T], T // Positive[T] and T % Positive[T] return T without a zero check, including augmented assignment. A domain dividend still requires .get(); Positive also rules out the signed MIN // -1 overflow case.
  5. len() does not convert. It stays i64, and a length becomes a limit through NonNegative.try_from. This can be reopened if call sites show friction.
  6. Unsigned forms are rejected. Positive[u64] is a compile error that points to NonZero[u64], and NonNegative[u32] one that points to u32.