Skip to main content

Newtypes

A newtype is a struct declaring exactly one unnamed field, which must be a register-sized scalar. It gives that scalar its own name in the type system while staying that scalar at runtime.

struct TileId(u8);
struct Q10(i16);

A newtype is not an aggregate. A TileId is one byte in a register: it passes and returns by value, assigns with a plain =, and its methods take self by value. Nothing about the generated code differs from using the bare payload type.

Why Newtypes Exist

R65 offers three ways to name a scalar, and they trade off differently:

type q10 = i16;              // transparent: `q10 + i16` type-checks
struct Q10v { value: i16 } // nominal, but an aggregate: passed as *Q10v
struct Q10(i16); // nominal AND free
FormDistinct to the checker?Pass semanticsRuntime cost
type Alias = TNoSame as TNone
struct S { f: T }YesBy reference (*S)Pointer indirection
struct S(T)YesBy value, in a registerNone

The newtype is the only form that is both nominal and free. Use it when a raw number carries a meaning the compiler should enforce — a tile index, a fixed-point value, a frame counter.

Payload Types

The payload must be a scalar that fits in a register.

AllowedRejected
u8 i8 boolStructs and unions
u16 i16Arrays
Any enumfar *T (3 bytes)
Any near pointer (*T), including a near fn pointerAnother newtype
struct Facing(Direction);    // enum payload — OK
struct Handle(*Sprite); // near pointer payload — OK

struct Ref(far *u8);
// error: newtype 'Ref' wraps 'far *u8', which is 3 bytes

struct Celsius(Temp);
// error: newtype 'Celsius' cannot wrap another newtype 'Temp'
// hint: wrap the payload type directly: 'struct Celsius(i16);'

Declaring two or more unnamed fields is not a newtype and is rejected outright:

struct Point(u8, u16);
// error: a newtype wraps exactly one field
// hint: give each field a name: 'struct Point { field0: u8, field1: u16 }'

Transparent In, Opaque Out

The conversion rule is one-directional. Payload values flow into a newtype implicitly; a newtype never flows out without an explicit .0 or as.

let t: TileId = 5;        // OK — payload flows in
let u: TileId = t + 1; // OK — result stays a TileId

let n: u8 = t;
// error: type mismatch in let binding: expected u8, found TileId

let n: u8 = t.0; // OK — explicit unwrap, 0 cycles
let n: u8 = t as u8; // OK — same

.0 is the payload accessor. It is read-only — assign the whole value instead of writing through it. Any index other than 0 is an error, and .0 on a named-field struct is too.

The rule holds in every position that checks a type, including function returns, so a newtype cannot launder itself back into its payload on the way out:

fn a() -> i16 { let q: Q10 = 5; return q; }   // error: returning 'Q10' from '-> i16'
fn b() -> i16 { let q: Q10 = 5; return q.0; } // OK
fn c() -> Q10 { let n: i16 = 5; return n; } // OK — transparent in

Construction and Casting

Q10(x) and x as Q10 both compile to nothing. They differ only in strictness: construction is checked exactly like an assignment into the payload, while as truncates the way it does for any other type.

let t = TileId(5);        // construction
let t = TileId(300);
// error: integer literal 300 does not fit in type u8 (valid range: 0 to 255)

let t = 300 as TileId; // OK — `as` is the truncating spelling
CastBehavior
Newtype as TUnwrap to the payload (0 cycles)
T as NewtypeWrap, truncating if the payload is narrower
Newtype as NewtypeThrough the payloads, truncating if narrower

Both spellings are also const-evaluable, so a newtype works in a const or an array size:

const BLANK: TileId = TileId(5);
const SHIFTED: TileId = 300 as TileId; // truncates, like any cast

An associated constant keeps its nominal type when it is folded, so Color::WHITE comes back a Color, not a bare u16.

Operators

A newtype inherits its payload's operators, and the result keeps the nominal type:

let a: TileId = 5;
let b: TileId = a + 1; // TileId
let c: TileId = ~a; // unary operators too
let d: TileId = a << 2;
let e: bool = a < b; // comparisons still yield bool

Two different newtypes never mix, even with identical payloads:

struct Q10(i16);
struct Ticks(i16);

let a: Q10 = 5;
let b: Ticks = 6;
let c = a + b;
// error: operator '+' has mismatched types 'Q10' and 'Ticks'
// hint: newtypes never mix; unwrap one side explicitly (e.g. 'Ticks(lhs.0 + rhs.0)')

That is the rule for values flowing implicitly. An as still converts between two newtypes, through the payloads, since as is the explicit escape hatch everywhere else in the language too:

let t: Ticks  = a as Ticks;     // OK: same payload, nothing to truncate
let u: TileId = a as TileId; // OK: the i16 payload truncates to u8

Operators cannot yet be overridden — see Current Limitations.

Using Newtypes

A newtype is usable anywhere a scalar of the same width is.

struct TileId(u8);

const BLANK: TileId = 0; // const, payload flows in

#[zeropage]
static mut CURRENT: TileId; // static

#[ram]
static mut MAP: [TileId; 8]; // array element

struct Holder { a: u8, t: TileId } // struct field

fn bump(t @ A: TileId) -> TileId { // register-bound parameter and return
return t + 1;
}

Note that an enum payload can bind a register even though a bare enum cannot — fn f(d @ A: Facing) is accepted where fn f(d @ A: Direction) is not, because a newtype is a value type by design.

Match

match sees through to the payload for patterns, and reports exhaustiveness under the newtype's own name:

match tile {
0 => { }
1..5 => { }
_ => { }
}
match tile { 0 => { } }
// error: Non-exhaustive match on TileId: add a wildcard pattern '_' to cover remaining values

A bool payload is exhaustive with true and false; an enum payload with all its variants.

Conditions

A condition consumes a value as a bool, which is the value flowing out -- the same rule that rejects let b: bool = f;. So a bool payload needs .0 in if, while, !, &&, and ||. A pattern is not a consumer, so a match on the wrapper needs no unwrap:

struct Flag(bool);

match f { true => { }, false => { } }; // OK: the value stays a Flag
if f.0 { } // OK: unwrapped for the condition

if f { }
// error: If condition must be boolean, found Flag
// hint: 'Flag' wraps a bool but does not flow out as one; unwrap it with
// '.0' (a 'match' on it needs no unwrap)

Methods

Newtype methods take bare self, by value, bound to the accumulator. Associated functions (no self) work as usual.

struct TileId(u8);

impl TileId {
fn zero() -> TileId { return TileId(0); } // associated fn
fn raw(self) -> u8 { return self.0; }
fn bumped(self) -> TileId { return TileId(self.0 + 1); }
fn is_blank(self) -> bool { return self.0 == 0; }
}

let t = TileId::zero();
let u = t.bumped().bumped(); // chains
CURRENT = CURRENT.bumped(); // receiver may be a static

bumped is the whole method — self arrives in A and the result leaves in A:

TileId__bumped:
.ACCU 8
.INDEX 16
INC A
RTS

raw is a retype of a value already in A, so it inlines away to nothing at the call site.

Mutation is expressed by returning a new value. There is no in-place form.

Rules for self

One self form per type. A newtype always takes bare self; a struct or union always takes *self. Writing the other form is an error either way:

impl TileId { fn raw(*self) -> u8 { return self.0; } }
// error: method 'TileId::raw' takes '*self', but 'TileId' is a newtype
// hint: newtype methods take 'self' by value: 'fn raw(self) -> TileId'

Neither A nor B is available to a parameter. B is the accumulator's high byte, not a register of its own, so self in A claims both:

impl TileId { fn add(self, n @ A: u8) -> TileId { return TileId(self.0 + n); } }
// error: parameter 'n' of 'TileId::add' binds A, which holds 'self'
// hint: a newtype method receives 'self' by value in A; bind 'n' to X or Y,
// or pass it on the stack

Bind the parameter to X/Y or pass it on the stack instead. The restriction is specific to a by-value self*self methods are stack-passed and claim neither register, and free functions may still bind @ B freely.

A 2-byte payload enters in m16, exactly as @ A: u16 does for a free function. An associated function with no self gets no synthesized receiver, so it stays in m8.

A pointer payload auto-dereferences through .0:

struct Handle(*Sprite);
impl Handle { fn x(self) -> u8 { return self.0.x; } }

Unlike trait methods — which receive *self in Y and are never inlined — newtype methods are ordinary static-dispatch functions and remain inlinable.

Traits

A newtype may implement a trait, but only for static dispatch. The impl's receiver form follows the implementing type rather than the trait declaration, so a newtype implements a *self-declared trait with bare self:

trait Drawable { fn draw(*self); }

impl Drawable for TileId {
fn draw(self) { } // bare self — the newtype's form wins
}

Forming a *dyn pointer over a newtype is rejected. Dynamic dispatch reads a TypeId byte at offset 0 of the pointee, and a newtype is all payload:

let d: far *dyn Drawable = &T as far *dyn Drawable;
// error: cannot form a '*dyn Drawable' over newtype 'TileId'
// hint: dynamic dispatch reads a TypeId byte at offset 0, and a newtype is
// all payload; call the method directly on the newtype instead

In practice the two rarely meet: a trait whose methods take self by value can only be implemented by a newtype, and one taking *self only by a struct, so a trait is naturally either dyn-able or newtype-able.

Clone is rejected for a different reason — it is redundant, not impossible:

impl Clone for TileId {}
// error: newtype 'TileId' cannot implement Clone
// hint: newtypes are copied by plain assignment: 'let b = a;'

let b: TileId = a; // copying needs no impl

Name-based resolution still reaches a newtype. format!("{s}", x) resolves to_string by name rather than by trait dispatch, so an inherent to_string on a newtype works — with no vtable and self by value.

Cost

Zero. Construction, .0, and as are all pure retypes that emit no instructions, and the payload lives in a virtual register like any other scalar. Code generated for a newtype is identical, instruction for instruction, to code generated for its payload type.

Current Limitations

Operators cannot be overridden. A newtype inherits its payload's operators and cannot replace them. struct Q10(i16) therefore gets integer multiply, when its multiply is semantically (a * b) >> 6 — which is why the standard library spells scaling multiply and divide as a.mul(b) and a.div(b).

Writing impl MulAssign for Q10 { ... } is accepted by the compiler but has no effect: a *= b still lowers to the inherited payload operator and never reaches the impl. Do not rely on it.

No far-pointer payload. struct Handle(far *Sprite); is 3 bytes and does not fit the return-register budget.

No nesting. struct Celsius(Temp); is rejected.

Complete Example

Q10 from the standard library (stdlib/Q10.r65) is a signed 10.6 fixed-point type — a distinct type at compile time, an ordinary 16-bit value at runtime:

struct Q10(i16);

const Q10_ONE: Q10 = 64; // 1.0 on the Q10.6 scale
const Q10_FRAC_MASK: i16 = 0x3F; // plain i16: it selects bits, it is not a value

impl Q10 {
#[inline(always)]
far fn from_int(n: i16) -> Q10 {
return Q10(n << 6);
}

#[inline(always)]
far fn from(n: i16, f: u8) -> Q10 {
return Q10((n << 6) | ((f as i16) & Q10_FRAC_MASK));
}

#[inline(always)]
far fn to_int(self) -> i16 {
if self.0 < 0 {
return 0 - (((0 - self.0) + 63) >> 6);
}
return self.0 >> 6;
}
}

#[zeropage]
static mut VELOCITY: Q10;

fn update() {
let player_x: Q10 = Q10::from_int(100); // 100.0
VELOCITY = Q10::from(0, 32); // 0.5 (32/64)

let next: Q10 = player_x + VELOCITY; // + and < inherited from i16
let screen_x: i16 = player_x.to_int(); // 100, explicitly unwrapped
}

An i16 flowing into a Q10 is accepted; a Q10 will not pass where an i16 is wanted, and will not mix with another newtype — so a scaled value cannot silently be consumed as a raw count.

See Also

  • Types — the full type system, conversions, and checking rules
  • Structs — named-field aggregates and *self methods
  • Traits — TypeId-based dynamic dispatch
  • ABI Models — parameter passing and register conventions