Map

Signed vs Unsigned Sizes

Wiki conceptlanguage-designtype-systems โ†ณ show in map Markdown
title
Signed vs Unsigned Sizes
type
concept
summary
The language-design choice between signed and unsigned size types โ€” the bug classes each enables, and the languages that picked each side
tags
language-design, type-systems
created
2026-05-03
updated
2026-05-03

Whether a language's default integer type for sizes, lengths, and indices is signed or unsigned. C made it unsigned (size_t) and most successors copied; Java dropped unsigned types entirely; Go picked signed; C3 flipped from unsigned to signed in 2026 after five years on the unsigned default.

The decision propagates through the type system: unsigned sizes mean unsigned indexing, which means unsigned arithmetic in any code that touches array lengths, which means signed-unsigned mixing everywhere, which forces the language into either implicit conversion (silent bugs) or pervasive casting (cast-spam culture).

Bug classes the unsigned default enables

  • Reverse-counting infinite loop. for (uint x = 10; x >= 0; x--) โ€” the comparison can never be false. The pattern is so common that C3 special-cased >= 0 as a compile error for unsigned types.
  • Signed-unsigned comparison flip. uint a = 0; int b = -1; if (a > b) โ€” C promotes to unsigned, so b becomes a huge positive number and the comparison is wrong. Languages that kept implicit conversion need an explicit "safe-comparison" rule to fix this.
  • Plausible-looking overflow. Signed overflow produces an invalid-looking negative number; unsigned overflow produces a plausible-looking positive number that's just wrong. The unsigned case survives casual testing.
  • Ring-buffer wrap. ((start - offset_back) % length + length) % length โ€” the natural negative-offset wrap pattern is silently broken with unsigned values, and no type-system rule can flag it. The correct unsigned form is uglier and less natural.
  • Modulo and division asymmetry. (foo + a) % 2 gives one answer when foo is signed and a different one when it's promoted to unsigned. Even with safe-conversion rules, / and % expose the underlying signedness in a way +, -, * don't.

Bug classes the signed default enables

  • Negative array index attempts. Caught at runtime as bounds-check failures; the language can't catch them statically without a richer type system.
  • Off-by-one near zero. Possible, but the failure mode is usually a clean negative number rather than a wrap-around to a billion.
  • Theoretical range loss. Half the representable range goes to negative values you mostly don't use. On 64-bit, you exhaust memory long before reaching 2^63 โ€” so the lost range doesn't bite. On 32-bit, indexing past 2 GB is rare enough that languages like Go just accept it.

Who picked what

Language Sizes Notes
C unsigned (size_t) The choice that propagated
C++ unsigned inherited from C; signed indexing has been argued for by Stroustrup and Sutter
Rust unsigned (usize) overflow is a runtime error, not wrap
Zig unsigned (usize) same convention as Rust
Java only signed no unsigned types at all
Go signed (int) by the team that knew C++'s pain firsthand
C3 signed (sz) since 0.8.0 switched in 2026 after 5 years on unsigned

The cast-spam vs minimize-casts dichotomy

A separate but linked design choice: when signed and unsigned types meet, does the language insert silent conversions or require explicit casts?

  • Cast-spam approach. Casts are cheap to write, "they document the conversion." Downside: a cast that was correct when written becomes silently truncating when an upstream type changes. Casts become "silence all warnings."
  • Minimize-casts approach. Casts mean "here be dragons." Cleaner in principle, harder to define rules for. C3 took this side, which forced the unsigned-sizes problem to bite โ€” because unsigned sizes mean signed-unsigned conversion is constantly needed, and a minimize-casts language has to make most of those conversions implicit.

The Rust experience is illustrative: unsigned sizes plus an "explicit conversions everywhere" stance produces a codebase full of as usize and as i64 calls. Mechanically inserted, they accumulate, and they don't actually catch the bugs they were supposed to document.

The "unsigned encodes a precondition" argument

The intuition behind unsigned-by-default is that "this value can't be negative" is a useful precondition โ€” making the type unsigned encodes it in the type system. This argument doesn't survive contact with verification tools: unsigned types encode "this wraps modulo 2^N," not "this is in [0, N)." The precondition was always an informal convention, and the type-system "guarantee" it provides is the wrong guarantee.

The internalized-cost observation

The most interesting non-technical claim from the C3 retrospective is that experienced C/C++ programmers stop seeing the cost of unsigned-everywhere โ€” they pay the cognitive overhead automatically and forget it's there. This is the laziness-as-virtue argument applied in reverse: the discipline that compensates for a tool's defect becomes invisible, and removing the defect feels like removing the discipline.

See also