Map
↑C3

Unsigned Sizes β€” A Five Year Mistake in C3

Wiki summarylanguage-designtype-systemsc3 ↳ show in map Markdown
title
Unsigned Sizes β€” A Five Year Mistake in C3
type
summary
summary
C3's creator on why defaulting sizes to unsigned was wrong, and why Go and Java got it right
parent
c3-lang
tags
language-design, type-systems, c3
created
2026-05-03
updated
2026-05-03

Christoffer Lerno's May 2026 post explaining why C3 is switching its default size type from unsigned to signed after five years. The framing is unusual β€” most "we changed our defaults" posts are about ergonomics; this one argues the original decision was incoherent at the language-design level, and that the compiler can't paper over it.

The chain that starts at sizeof

The core claim is that the textbook C unsigned bugs all descend from one earlier decision: standardizing sizeof as size_t. That made unsigned arithmetic common in C code, normalized "this can't be negative, so it's unsigned" as a typing idiom, and propagated into every successor language that copied C's size convention β€” including Rust and Zig.

Once sizes are unsigned, indexing is unsigned. Once indexing is unsigned, signed-unsigned mixing is everywhere, and the language has to either allow implicit conversion (silent bugs) or require casts everywhere (ergonomic disaster, casts become "silence all warnings").

The bugs Lerno walks through

  • for (uint x = 10; x >= 0; x--) β€” infinite loop. C3 had to special-case this comparison.
  • uint a = 0; int b = -1; if (a > b) β€” promotes to unsigned, comparison flips. C3 added safe mixed comparisons that don't convert.
  • Rust-style cast spam β€” code accumulates casts that "compile but lie" when the cast operand's type later changes.

C3 picked the minimize-casts approach with int + uint promoting to int to keep most expressions silently signed. Worked for five years.

Where it broke: division and modulo

The post turns on one example: (foo + a) % 2. With C3's promotion rule, this gives an obviously-wrong answer when foo > INT_MAX (the right code is % 2U). Everywhere else, you could ignore whether a subexpression was signed β€” / and % were the exception, and the convenience that hid the conversion made this exception harder to spot, not easier.

The immediate fix was to error on unsigned / signed and unsigned % signed. But the deeper problem was the ring-buffer case.

The ring buffer

Negative-offset wrap-around in a ring buffer:

index = ((start + offset) % length + length) % length;  // signed: works
index = ((start - offset_back) % length + length) % length;  // unsigned: silently wrong
index = (start + length - (offset_back % length)) % length;  // unsigned: correct

The unsigned-broken version often happens to wrap correctly, so it survives casual testing. No compiler can flag it β€” it's syntactically and type-system fine.

This is the one example in the post that Lerno frames as unfixable, not just unergonomic. With unsigned sizes, the natural code is wrong and the correct code is ugly. The language design forces a tradeoff that doesn't exist with signed sizes.

The range argument doesn't hold up

The standard defense of unsigned is "double the range." Lerno's counter: overflow on signed produces an invalid-looking negative number; overflow on unsigned produces a plausible-looking positive number β€” silently wrong, harder to catch. And on 64-bit machines you exhaust memory before reaching 2^63.

He also notes that making unsigned overflow an error (Rust's choice) breaks the algebraic identity (a + b) - c == a + (b - c), which only holds when wrapping is allowed. So tightening unsigned trades one trap for another.

Verification-framework experience (cited but not specified) is that unsigned encodes "this wraps modulo 2^N" rather than "this is in some range" β€” so even the type-as-precondition argument doesn't survive contact with proof tools.

What other languages did

  • Java dropped unsigned types entirely in the 90s. Lerno calls this "perhaps a little extreme" but acknowledges it eliminated the bug class.
  • Go, designed by people who knew exactly what unsigned sizes cost in C++, picked signed sizes. The body language of "low-level systems language by people with skin in the game" makes it the strongest counterexample.
  • C++, Rust, Zig all carry C's unsigned-size convention forward.

What C3 changed

  • isz / usz renamed to sz / usz, the asymmetric pair signaling which one is preferred.
  • Implicit signed↔unsigned conversion: dropped.
  • Mixed signed-unsigned comparisons: dropped.
  • The change is tagged "szmageddon" (originally "iszmageddon") in the C3 community.

The deeper note

Lerno's reflection at the end is the part worth keeping past the language details: he had internalized the cost of unsigned. After enough years in C/C++, you stop seeing the cognitive overhead of "is this expression unsigned-safe?" β€” you just pay it. The change felt wrong at first, "as if I was doing something forbidden." The signal that the original decision was bad wasn't a bug, it was the realization that a habit had been masking the cost.

This is the laziness-as-virtue argument applied to language design: the discipline that human engineers develop to compensate for a tool's defects becomes invisible, and removing the defect feels like removing the discipline.

See also