SFEP-TBD
Sized Integer Types and Overflow Semantics
- Status
- Draft
- Type
- language
- Created
- Updated
- Author
- agent:compiler-architect; human review
SFEP-XXXX — Sized Integer Types and Overflow Semantics
1. Summary
Sailfin recognises int (i64), float (f64), and a partial sized family
(i8/i16/i32/i64, u8/u16/u32/u64, usize/isize, f32/f64)
as type-name strings, and all of them already lower to real LLVM iN/double/
float types through map_primitive_type
(compiler/src/llvm/type_mapping.sfn:859-897).
But the family is only half real: unsigned widths collapse onto their signed
LLVM twins (u32→i32, u8→i8), so the compiler cannot tell signed from
unsigned; there is no defined overflow behaviour; there is no
literal-fits-in-type checking; and there are no typed literal suffixes.
This SFEP finishes the family into a first-class, sign-tracked set with defined
overflow semantics (wrap in release / trap in debug, matching Rust), explicit
as-cast conversions (no implicit widening or narrowing), and a wrapping_* /
checked_* / saturating_* library surface (no new operators). It is
foundational plumbing — “fix the foundation first” — and is deliberately
independent of the generics workstream.
2. Motivation
A “systems language” without a working sized-integer family is an outlier.
Rust, Go, Swift, Zig, and C all ship the full {i,u}{8,16,32,64} family with
defined conversion and overflow rules. Sailfin’s status matrix marks the numeric
types “Shipped”
(docs/status.md:184), but that row describes the
int/float (i64/f64) core plus bitwise/shift ops and the as matrix — not a
sound sized family. Three concrete gaps make the current state unsafe to build
on:
-
Signedness is silently lost.
map_primitive_typemapsu32→i32,u16→i16,u8→i8,u64→i64(type_mapping.sfn:877-895). The cast-lowering matrix documents the resulting footgun in a standing “KNOWN LIMITATION” comment (core_literals_lowering.sfn:1837-1845):“source-level unsigned widths (
u8/u16/u32) collapse toi8/i16/i32…255_u8 as u64therefore lowers assextand becomes-1(sign- extended) rather than255(zero-extended).”Every
u*→wider conversion is wrong today.u8value255widened tou64yields-1. This is a miscompile, not a missing feature. -
Overflow is undefined. No
overflow,wrapping_*,checked_*, orsaturating_*machinery exists anywhere incompiler/src(a repo-wide search finds only unrelated build-cache/lifetime hits). Integer arithmetic emits bareadd/sub/mulwith no defined behaviour on wrap and no way for a user to request wrap, trap, or checked arithmetic. For a memory-safety-floor language (ownership epic #1209) this is a hole: an overflow that produces a bad index or length is exactly the class of bug the ownership floor otherwise closes. -
Literals are not range-checked and cannot be typed. The lexer captures a number as one generic
NumberLiterallexeme with no suffix support (lexer.sfn:190-280); a trailingu8/i16lexes as a separateIdentifiertoken. There is no parse-time or check-time bounds check, solet x: u8 = 300compiles and truncates silently. Rust/Swift reject this at compile time.
Who hits it: anyone doing FFI (libc mode_t/uid_t are u32, dev_t is
u64, ssize_t is isize), byte/buffer work (u8 for bytes), bit-packing,
checksums/hashing, and anyone porting Rust/Go/C code — which is the LLM-generated
majority (AI agents are users; they emit u32/usize/wrapping_add from muscle
memory and get silent miscompiles). The spec already advertises the family for
“FFI and low-level use”
(spec/06-types.md:21-22),
so the safety claim is being made without the enforcement behind it — precisely
the “parsed but not enforced” anti-pattern.
3. Design
3.0 Scope split (1.0 vs. deferred)
- In 1.0 (this SFEP): the full sized family as distinct sign-tracked
types; typed literal suffixes; literal-fits-in-type checking; explicit
as-cast conversions with correct sign/zero-extension and truncation; a defined default overflow semantics (wrap in release / trap in debug); and thewrapping_*overflow-safe method family as the escape valve for the default trap.f32completes the float side. - Deferred (post-1.0, designed here so the 1.0 surface doesn’t paint us into
a corner):
checked_*(which needsResult/Optionreturn shapes gated on generics) andsaturating_*. These are library methods, addable without any syntax or new keyword once generic returns land.
The core principle throughout: libraries over keywords. Overflow policy (wrap/checked/saturate) is expressed as method calls, never as new operators or keywords. Only the default behaviour and the type names are language-level.
3.1 The type family (types are strings; add sign+width metadata)
Sailfin threads type annotations as raw strings end-to-end (TypeAnnotation { text: string } at ast.sfn:12-14;
NativeFunction.return_type: string at
native_ir.sfn:73-77). We keep that
model — no structured width field is added to the AST or IR. Signedness and
width are derived from the type-name string at each site that needs them, the
same way map_primitive_type already resolves by string match. This is the
minimal-blast-radius choice and keeps the .sfn-asm wire format unchanged.
The canonical family, its LLVM lowering, and its signedness:
| Sailfin type | LLVM type | Signed? | Notes |
|---|---|---|---|
i8 i16 i32 i64 |
i8 i16 i32 i64 |
signed | int is an alias for i64 |
u8 u16 u32 u64 |
i8 i16 i32 i64 |
unsigned | same LLVM repr; sign tracked in the frontend |
usize isize |
i64 |
unsigned / signed | pointer-width; i64 on all current 64-bit targets |
f32 f64 |
float double |
— | float is an alias for f64; number a deprecated alias for float |
bool |
i1 |
— | distinct scalar kind (§spec 06); unchanged |
Sign is a frontend concept, not an LLVM concept. LLVM integers are
sign-agnostic; signedness lives in the operations (sext vs zext, sdiv
vs udiv, icmp slt vs icmp ult). So the design adds a single source-of-truth
helper — integer_type_is_signed(name: string) -> boolean and
integer_type_width(name: string) -> int — colocated with map_primitive_type,
and routes every width/sign-sensitive lowering decision through it. The i*
names are signed, the u* names unsigned, usize unsigned, isize signed.
Recognition extends the existing string-match resolvers rather than adding a new mechanism:
is_extern_primitive_type(typecheck_types.sfn:1057-1075) already accepts the whole family — no change needed for the FFI boundary.- The general type surface already flows through
map_type_annotation→map_primitive_type, which already maps every family member — solet x: u16in ordinary (non-extern) code already lowers. The gap this SFEP closes is correctness (sign) and safety (overflow, literal bounds), not mere recognition.
3.2 Literals: default i64, optional typed suffix, range-checked
Default type. An unsuffixed integer literal is int (i64); an unsuffixed
decimal/exponent literal is float (f64). Unchanged — this is already the shipped
default (spec/06-types.md:24-30).
Typed suffixes. A literal may carry a type suffix to fix its type without an
as cast: 100u8, -5i16, 0xffu32, 1_000_000i64, 3.5f32. Grammar: the
suffix is one of the ten integer names (i8 i16 i32 i64 u8 u16 u32 u64 usize isize) or the two float names (f32 f64) immediately following the digit run
(and after any _ separators / fraction / exponent). An optional single _
before the suffix is accepted for readability (100_u8) to match Rust and to
sidestep the e-exponent ambiguity (1e2 vs a hypothetical 1_e...).
Lexer change. Today the number scanner stops at the last digit/exponent byte
and the suffix would lex as a separate Identifier
(lexer.sfn:274-280). Extend the
scanner: after the numeric run, if the next bytes (optionally preceded by one
_) spell one of the twelve suffix names, consume them into the same
NumberLiteral lexeme. The token kind stays NumberLiteral; the suffix travels
in the lexeme and is split off downstream (mirroring how the parser already
disambiguates numeric literals). This is additive — a suffix-free literal lexes
byte-for-byte identically, preserving self-host.
Literal-fits-in-type checking (E0824). When a literal’s type is fixed
(either by suffix, or by a same-statement annotation such as
let x: u8 = 300), the checker verifies the value fits the type’s range:
u8 ∈ [0, 255], i8 ∈ [-128, 127], u16 ∈ [0, 65535], etc. Out-of-range is a
compile error E0824 with the type’s range in the message and a fix-it
suggesting the next-wider type. This is a new typecheck rule (there is no
literal-bounds check anywhere today); it is a pure frontend check with no codegen
dependency, and it mirrors the existing string-driven diagnostic style in
typecheck_types.sfn (e.g. the ?-operator rules at
typecheck_types.sfn:402-438).
let a = 100u8; // u8, in range — oklet b: u16 = 65535; // in range — oklet c: u8 = 300; // E0824: literal 300 out of range for `u8` (0..=255)let d = -5i16; // i16 — oklet e = 4_000_000_000u32; // in range for u32 (max 4_294_967_295) — oklet f: i32 = 3_000_000_000; // E0824: out of range for `i32` (-2_147_483_648..=2_147_483_647)3.3 Conversions: explicit as only (no implicit widening/narrowing)
No implicit conversions between integer types — matching Rust’s systems
discipline. A u16 does not implicitly become a u32; a value of one integer
type flowing into a binding, argument, field, or arithmetic operand of a
different integer type is an error with an as fix-it. This tightens today’s
behaviour: dominant_type currently silently widens same-kind pairs
(i8 ↔ i64 → i64) with explicit branches only for i8/i32/i64
(core_operands.sfn:2495-2557)
and no branches for i16/the whole u* family (they fall through to the
return first catch-all — a latent ABI-mismatch bug the status note at
status.md already flags). Under this SFEP:
- Arithmetic and comparison require both operands to be the same integer
type. Mixed integer widths/signs produce a
[fatal]ABI-mismatch diagnostic with anasfix-it — reusing the exact mechanism that already rejectsint ↔ float(core_operands.sfn:2597-2607), extended from the int/float kind-split to a per-(width, sign)identity. dominant_typestops silently widening across integer types. Its same-kind widening branches are removed for the integer family; only exact identity (already thefirst == secondfast path) passes without an explicit cast. This is the tightening the spec’s “coercion within a kind” note (spec/06-types.md:32-54) currently permits and this SFEP retracts for integers (floatf32→f64widening viafpextis retained as a follow-on decision; see §6).
as-cast semantics (the correctness fix). The Cast AST node already exists
(ast.sfn:95) and the effect checker already
walks the operand
(effect_checker.sfn:888-897,
#1627). The lowering matrix
(core_literals_lowering.sfn:1823-1888)
is extended to choose the extend/truncate opcode from the source type’s
signedness, closing the standing KNOWN LIMITATION:
| From → To | Rule | LLVM op |
|---|---|---|
| narrower → wider, source signed | sign-extend | sext |
| narrower → wider, source unsigned | zero-extend | zext |
| wider → narrower (any sign) | truncate | trunc |
same width, sign flip (i32↔u32) |
bit-reinterpret, no instruction | (identity) |
| int → float | signed → sitofp, unsigned → uitofp |
|
| float → int | signed target → fptosi, unsigned target → fptoui |
|
f32↔f64 |
fpext / fptrunc |
|
bool (i1) → int |
zext (true→1) |
(unchanged) |
any → bool via as |
rejected; use a comparison | (unchanged, E0537-style) |
The lowering already emits sext/trunc/sitofp/fptosi/fpext/fptrunc
correctly by width — the only change is selecting zext vs sext and uitofp/
fptoui vs the signed forms by consulting integer_type_is_signed on the
source (for extension) and target (for float↔int). Because sign is
tracked in the frontend, the lowering needs the source Sailfin type, not just
the LLVMOperand.llvm_type (which is sign-agnostic). This is threaded via the
already-present parse.type_annotation on the cast plus the operand’s inferred
Sailfin type; where the operand’s Sailfin type is not available (untyped
temporaries), the checker’s inferred-type annotation carries it — the same
kind-tag threading spawn_future_kind already relies on
(typecheck_types.sfn:464-481).
Truncation is defined as keeping the low N bits (two’s-complement wrap);
300u16 as u8 == 44. Sign/zero extension is defined by the source sign.
These are the standard C/Rust as semantics and are documented normatively in
the spec chapter.
3.4 Overflow: default = trap in debug / wrap in release; wrapping_* escape
Default overflow semantics. Integer +/-/* (and negation, <<) that
overflow the operand type:
- trap in debug builds (a runtime panic with a clear message, e.g.
attempt to add with overflow (i32)), and - wrap (two’s-complement) in release builds.
This is Rust’s model, chosen deliberately over the three alternatives (§6): it gives developers a loud signal during development (catching the overflow bug where it happens) while paying zero steady-state cost in release. It composes cleanly with the ownership memory-safety floor (an overflowed length/index traps instead of silently producing an out-of-bounds value).
Build-mode plumbing. The compiler already distinguishes build configurations
through the driver; overflow-checking is gated on a debug/release flag threaded
into TypeContext/lowering. In debug, integer add/sub/mul lower to the
LLVM overflow intrinsics (llvm.sadd.with.overflow.iN / uadd per sign) and
branch to a trap block calling a runtime sfn_panic_int_overflow helper; in
release they lower to plain add/sub/mul (wrap). The per-sign choice
(sadd vs uadd, sdiv/udiv, icmp slt/ult for comparisons) is again
driven by integer_type_is_signed. Division/remainder by zero already needs a
trap and rides the same helper path.
The wrapping_* escape valve (in 1.0). To request wrap explicitly even in
debug, integer types expose method-call helpers — library functions in the
prelude/runtime, not operators or keywords:
let a: u8 = 250;let b = a.wrapping_add(10u8); // 4, never trapslet c = a.wrapping_sub(255u8); // wraps, never trapswrapping_add/wrapping_sub/wrapping_mul/wrapping_neg/wrapping_shl/
wrapping_shr are concrete per-width runtime functions (sfn_wrapping_add_u8,
… _i64) dispatched by method call on an integer receiver — the same
receiver-method dispatch the runtime already uses. They lower to plain wrapping
LLVM ops regardless of build mode. Being ordinary functions, they add no
keyword and can never shadow a variable name.
Deferred to post-1.0 (designed, not built): checked_add → Result/
Option (needs generic returns, gated on the generic-constraints foundation),
and saturating_add → clamp to the type’s min/max. Both are pure library
additions requiring no further language change once generic returns exist.
3.5 Worked end-to-end example
fn checksum(bytes: u8[]) -> u32 ![pure] { let mut sum: u32 = 0u32; let mut i: int = 0; loop { if i >= bytes.length { break; } // bytes[i] is u8; widen explicitly (zero-extend) to u32 to add sum = sum.wrapping_add(bytes[i] as u32); i += 1; } return sum;}
fn narrow(x: u32) -> u8 ![pure] { return x as u8; // trunc — keeps low 8 bits, defined}
// Rejected cases:// let n: u8 = 300; // E0824 — literal out of range for u8// let s: u32 = an_i32_value; // E0825 — implicit int-type conversion; use `as`// let m = a_u16 + a_u32; // [fatal] ABI mismatch — mixed integer widths; use `as`4. Effect & capability impact
None to the effect system’s semantics. Integer types, casts, literals, and
overflow policy are effect-free: a cast/arithmetic node carries no effect of its
own, and the effect checker already walks the operand of Cast
(effect_checker.sfn:888-897)
so an effectful operand’s requirement (readByte() as u32) still surfaces
correctly. The wrapping_* methods are ![pure] (arithmetic only). The
debug-mode overflow trap is a runtime panic (like existing bounds/match
backstops), not an effect — it needs no capability, exactly as
match_exhaustive_failed and index-bounds traps do not. The canonical six
effects and the taxonomy lock (SFEP-0017) are untouched.
5. Self-hosting impact
Passes that change, in pipeline order:
- Lexer (
lexer.sfn) — number scanner consumes an optional trailing type suffix into theNumberLiterallexeme. Additive: suffix-free literals lex identically, so the old seed and new source agree on every existing token. - Parser (
parser/expressions.sfn) — splits the suffix off the literal lexeme and records the literal’s fixed type. Additive; no grammar the old seed rejects. - Typecheck (
typecheck_types.sfn,typecheck.sfn) — newinteger_type_is_signed/integer_type_widthhelpers; literal-range check (E0824); implicit-integer-conversion rejection (E0825); the new mixed-integer arithmetic rejection. - Emit / lowering (
llvm/type_mapping.sfn,llvm/expression_lowering/native/core_literals_lowering.sfn,.../core_operands.sfn) — sign-awareasopcodes (closes the KNOWN LIMITATION), sign-awaresdiv/udivandicmp slt/ult, and the debug-mode overflow-intrinsic path with the release wrap fallback. - Runtime prelude (
runtime/sfn/…) —sfn_wrapping_*_{i,u}{8,16,32,64}helpers andsfn_panic_int_overflow.
Self-hosting is preserved by additivity and staging. The lexer/parser changes
are additive (the old seed’s tokenisation of the current, suffix-free compiler
source is unchanged), and the compiler source does not adopt typed suffixes,
u* types, or wrapping_* in the same PR that introduces them — the seed
transition is:
- PR 1 (this capability): land recognition + literal checking + sign-aware
casts + overflow default +
wrapping_*in the compiler, without using any of it incompiler/src.make compilebuilds the new compiler from the old seed (which needs none of the new features to compile the unchanged compiler source), then that new compiler self-hosts. No seed cut required — this bundles the capability with a no-op consumer. - After a seed cut that pins the new binary: subsequent PRs may migrate
compiler/srcFFI/byte code to realu*/usizespellings andwrapping_*. Those migrations are gated behind the pinned seed per the seed-dependency rule.
Because the default overflow behaviour in release is wrap (identical to
today’s bare add/sub/mul), and the compiler is built in release, turning on
the overflow default does not change the emitted IR for the existing compiler
source until debug builds opt in — the fixed-point self-host (make check) stays
byte-identical for the release build.
6. Alternatives considered
- Leave the family half-real (status quo). Rejected: the
u*→widersextmiscompile is a live correctness bug (255u8 as u64 == -1), and the spec already advertises the family — shipping the syntax without the semantics is the “parsed but not enforced” anti-pattern. - Structured width/sign field on
TypeAnnotation/IR instead of deriving from the type-name string. Rejected: it would churn the AST, the.sfn-asmwire format, and every pass that threads type strings, for zero expressiveness gain — the rest of the compiler already resolves types by string match, and a derive-on-demand helper is strictly less blast radius while giving the same answer. - Overflow default = always wrap (Go/C unsigned model). Rejected as the
default: silent wrap hides real bugs (the class the ownership floor is
otherwise closing). Wrap remains available explicitly via
wrapping_*and is the release behaviour, but debug traps so bugs are found. Matching Rust here also matches what LLM-generated code expects. - Overflow default = always trap (Swift model). Rejected as the default: a trap on every release-build arithmetic imposes a steady-state branch cost and a panic surface in hot paths (checksums, hashing) where wrap is the intended semantics. Debug-trap / release-wrap gives the safety signal without the release cost.
- Overflow default = saturate. Rejected: saturation is a domain choice (DSP,
graphics), not a sane silent default for general arithmetic; it is offered as
an explicit
saturating_*method post-1.0. wrapping_*/checked_*as operators or keywords (e.g.+%,+?). Rejected: violates “libraries over keywords” — a keyword can never be a variable name, and these are expressible as ordinary methods with no expressiveness loss. Boring, conventional method names also match Rust exactly, reducing LLM error rates.- Ship
checked_*/saturating_*in 1.0. Deferred:checked_*needs aResult/Optionreturn, which depends on the generic-constraints foundation (companion0038-generic-constraints.md). Defining the default overflow semantics andwrapping_*now is the load-bearing 1.0 slice; the checked/ saturating library methods slot in later with no language change. - Retract
f32→f64implicit widening too. Left as a follow-on: floats are a single kind in the current spec coercion model, andfpextwidening is lossless; this SFEP scopes the integer implicit-conversion retraction and leaves the float-widening decision to the spec’s existing kind rules.
7. Stage1 readiness mapping
- Parses — typed suffixes lex+parse (
lexer.sfn,parser/expressions.sfn). - Type-checks / effect-checks — literal-range
E0824, implicit-conversionE0825, mixed-width arithmetic rejection; effect checker already walksCastoperands (no change needed). - Emits valid
.sfn-asm— type strings ride the existing string channel; no wire-format change. - Lowers to LLVM IR — sign-aware
sext/zext/uitofp/fptoui, sign-awaresdiv/udiv/icmp, debug overflow-intrinsic path + release wrap;wrapping_*runtime helpers. - Regression coverage — see §8.
- Self-hosts — additive lexer/parser; compiler source unchanged in the landing PR; migration behind a pinned seed (§5).
-
sfn fmt --checkclean — formatter round-trips suffixed literals. - Documented —
docs/status.mdnumeric rows updated;reference/spec/06-types.mdgains the sized-family, conversion, and overflow sections.
Per SFEP-0001 §4, this stays Accepted (not Implemented) until every box is checked and it self-hosts — the overflow trap especially must be enforced end-to-end, not merely parsed.
8. Test plan
Unit (compiler/tests/unit/):
lexer_int_suffix_test.sfn—100u8,-5i16,0xffu32,1_000i64,3.5f32,100_u8; and that1e/1e2(exponent) still lex as float, not a suffix.typecheck_int_literal_range_test.sfn—u8 = 300→E0824; boundary values (255u8ok,256u8error;-128i8ok,-129i8error;u32max ok / +1 error).typecheck_int_implicit_conversion_test.sfn—let s: u32 = an_i32→E0825withasfix-it;a_u16 + a_u32→ ABI-mismatch[fatal]; same-type ops ok.sign_helpers_test.sfn—integer_type_is_signed/integer_type_widthtruth table for all ten integer names +usize/isize.
Integration / e2e (compiler/tests/e2e/):
int_cast_sign_test.sfn— the regression that closes the KNOWN LIMITATION:255u8 as u64 == 255(zext),(-1i8) as i64 == -1(sext),300u16 as u8 == 44(trunc),(-1i32) as u32bit-reinterpret,3.5f64 as i32 == 3,(-1i32) as f64 == -1.0. Assert on runtime output viaprocess.run_capture.int_overflow_default_test.sfn— a debug build ofi32addthat overflows traps with the overflow message (build the fixture with the debug flag, assert non-zero exit + message); the release build wraps to the two’s- complement value. Follows the build-and-run isolation rules (SAILFIN_TEST_SCRATCH, threadedPATH).int_wrapping_methods_test.sfn—250u8.wrapping_add(10u8) == 4,wrapping_sub/wrapping_mul/wrapping_negper width; never traps in debug.int_unsigned_arithmetic_test.sfn—u32division/comparison useudiv/icmp ult(a largeu32compares>a small one where the signed interpretation would flip).
Snapshot: an IR snapshot (expect_snapshot) over a small module exercising
u8→u64 (zext), i8→i64 (sext), and a debug overflow-checked add to lock the
opcode selection.
9. References
- Code (the gap):
compiler/src/llvm/type_mapping.sfn:859-897(the u*→signed collapse),compiler/src/llvm/expression_lowering/native/core_literals_lowering.sfn:1837-1845(the standing “KNOWN LIMITATION” comment),compiler/src/llvm/expression_lowering/native/core_operands.sfn:2495-2557(dominant_type— i8/i32/i64-only branches, silent same-kind widening),compiler/src/lexer.sfn:190-280(no suffix support),compiler/src/typecheck_types.sfn:1057-1075(is_extern_primitive_typeaccept-list),:464-481(string-driven kind resolution precedent),compiler/src/ast.sfn:12-14(TypeAnnotationis text),:95(Castnode),compiler/src/effect_checker.sfn:888-897(Cast operand walk, #1627),compiler/src/native_ir.sfn:73-77(string-threaded types). - Status / spec:
docs/status.md:184-185(numeric types + bitwise ops “Shipped”),site/src/content/docs/docs/reference/spec/06-types.md(primitive table, sized-family FFI note, scalar coercion kinds). - Related SFEPs / work: SFEP-0025 §3.7 (
0025-native-runtime-architecture.md, theint/floatnumeric-types decision this extends), SFEP-0017 (0017-hierarchical-effects.md, the taxonomy lock this leaves untouched), companion0038-generic-constraints.md(the generics foundation thatchecked_*→Result/Optiondepends on — deferred post-1.0),.claude/rules/ seed-dependency.md(the bundle-vs-split seed rule applied in §5). - Prior art: Rust integer types +
assemantics + debug-trap/release-wrap overflow +wrapping_*/checked_*/saturating_*; Swift trapping arithmetic and&+wrapping operators; Go’s fixed-sizeintN/uintNwith defined wrap.