Guides

Newtypes, generics and decimals

A newtype crosses as the value it wraps and arrives as a distinct type. A generic type gets a name per use. Money is a Decimal, never a float.

Newtypes#

A tuple struct with one field is a newtype. It crosses as the value it wraps, with no length and no tag, so it costs nothing on the wire, and each platform gets a distinct type, so an AccountId can never be passed where a UserId is wanted.

In RustSwiftKotlinTypeScript
struct AccountId(pub Uuid)struct AccountId: RawRepresentable, Hashable, Codable@JvmInline value class AccountId(val value: UUID)type AccountId = string & {{ __brand }} and a function AccountId(value)
Show the code for Rust, Swift, Kotlin and TypeScript
rust
/// The identity of an account.
#[undra::api]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct AccountId(pub Uuid);

#[undra::api]
pub fn open_account(ctx: &Ctx, owner: String) -> AccountId { .. }

#[undra::api]
pub fn balances(ctx: &Ctx) -> HashMap<AccountId, Price> { .. }
swift
let id: AccountId = try openAccount(owner: "Ada")
let raw: UUID = id.rawValue                 // a UUID inside, an AccountId outside
let all: [AccountId: Price] = try balances()  // Hashable, so a dictionary key
kotlin
val id: AccountId = openAccount("Ada")
val raw: UUID = id.value
val all: Map<AccountId, Price> = balances()
ts
const id: AccountId = await openAccount("Ada");
const raw: string = id;                       // a plain string at run time
const other: UserId = id;                     // a type error: the brands differ
const all: Map<AccountId, Price> = await balances();

The inner type is any value type: another newtype, a record, a Vec, an Option, a Decimal. A newtype can stand anywhere a record can, and as a map key when the type it wraps is a valid key (String, an integer, bool, Uuid); the compiler checks it, so HashMap<Price, _> with struct Price(Decimal) is an error at the map, not a surprise on a phone. A keyed list may use a newtype as its key field.

Swift and Kotlin add Comparable when the wrapped type has an order that means something (numbers, strings, dates, durations, decimals). A Uuid gets none: Foundation's UUID is comparable only from iOS 17. No literal conformances are generated, because a literal that becomes an AccountId is what the type is there to prevent.

Wrapping a field you already persist in a newtype needs no migration code: the bytes are the same, so an old snapshot or cached query result reads back as it was.

Generics#

Generic code crosses as named instantiations: you list the types, the platforms get an ordinary function, record, class or store for each. Data types first; generic functions, objects and stores follow, with the limits.

Generic data types

The schema describes concrete types, so a generic struct or enum is a template that registers nothing, and each use is a named instantiation. The name is yours and stays stable, which is what the generated Swift, Kotlin and TypeScript need.

Show the Rust code
rust
#[undra::api(generic)]
pub struct Slice<T> {
    pub items: Vec<T>,
    pub next: Option<u32>,
}

#[undra::api(generic)]
pub enum Loadable<T> {
    Loading,
    Loaded(T),
    Failed(String),
}

#[undra::api] pub type EntrySlice = Slice<Entry>;
#[undra::api] pub type LoadableEntries = Loadable<EntrySlice>;

#[undra::api]
pub fn statement(ctx: &Ctx, account: AccountId, offset: u32, limit: u32) -> EntrySlice { .. }

On every platform EntrySlice is an ordinary record and LoadableEntries an ordinary enum, byte for byte what you would get by writing them out. Signatures spell the alias; writing Slice<Entry> in one is E0002 and the message says which alias to declare. An alias lives in the crate of its template (a second one for the same instantiation is E0070), and its arguments are concrete types, other aliases and newtypes included.

Generic functions, methods, objects and stores

The same shape, one level up (ADR-058). A function lists its types, #[undra::api(generic(T = [Todo, Note]))], and each is one function: Swift and Kotlin overload it, TypeScript takes the type's name first (newest("Todo", rows)). An object or a store over a type parameter is a template, #[undra::api(store, generic)] impl<T: Row> Selection<T>, and each alias, #[undra::api] pub type TodoSelection = Selection<Todo>;, is an ordinary store of that name with its own type id, handles, signals and snapshot. The guide has the Rust and what Swift, Kotlin and TypeScript call, and where to put the alias.

What is not there

RefusedCodeWhy, and what would lift it
A generic function with no list (an open-ended T)E0002Callers are on the platforms, so the core must contain what they can call. Nothing lifts it: this is the design.
Two type parameters on a function or methodE0072Each listed type is one function; two parameters need pairs, which need a syntax and names for pairs on the platforms.
A scalar, a String, a container or an object in a function's listE0072An instantiation is presented under the type's name; unnamed types would need naming rules in three languages. Wrap it in a newtype.
A generic method inside a generic objectE0074An instantiation is one class; a second list multiplies it. Tests only: the expansion composes.
A generic constructorE0002A constructor returns the object, whose type has no such parameter.
Generic queries and mutationsE0002A query is cached, persisted and invalidated under one name and key; instantiations need keys and handle names of their own.
Generic ports, callback traits and error enumsE0002The platform implements a port once, by the trait's name, and an error is thrown by name; named instantiations of traits would lift it.
An alias in another crate than its templateE0070The instantiation implements the boundary traits for the template's type, which only its crate may do.
Native generics: func newest<T>, Selection<Todo> as a generic classnoneThe schema would need type parameters and described type sets. The overloads stay source-compatible with that.

Lifetimes are E0003 and const generics E0002, as before; bounds and where clauses are yours and are checked at the alias, by rustc.

Decimal, for money#

Decimal is an exact decimal number: a 128-bit mantissa and a scale of at most 38, so 19.99 is 1999 with scale 2. It never goes through a float. The scale is part of the value, so 1.10 and 1.1 are different values to == (and to a map) and the same number to a numeric comparison; Decimal is therefore not a map key.

PlatformTypeNotes
Rustundra::Decimal { mantissa: i128, scale: u8 }exact FromStr and Display, cmp_numeric; no arithmetic. With the rust_decimal feature, rust_decimal::Decimal crosses too; a value it cannot hold (more than 96 bits of mantissa, or a scale above 28, which a Swift 1 / 3 has) is a decode error, so round on the platform first
SwiftFoundation.Decimaldecoding is exact; == and hashing are numeric (1.0 == 1.00, unlike the other three); a NaN encodes as zero, 2127 or more saturates, digits past the 38th after the point (or past a 127-bit mantissa) are cut off
Kotlinjava.math.BigDecimalequals is scale-sensitive, compareTo is numeric, as Kotlin engineers expect; digits past the 38th after the point (or past a 127-bit mantissa) round half up, a whole part of 2127 or more saturates
TypeScriptDecimal of @undra/runtimemantissa: bigint, scale, parse, toString, equals, compare; no arithmetic, convert through toString() to your decimal library

A scale above 38 is refused by every decoder, in the core as much as on the platforms. i128 and u128 stay out; the error points here, or at a String newtype for identifiers.

Types from other crates#

The types most Rust teams already use cross as the wire type they stand for, behind features of the undra crate. Each dependency is built without a clock or a randomness feature, so a core that uses them still builds for wasm32, iOS and Android, and still reads no clock of its own.

FeatureCrosses as
uuiduuid::Uuid as Uuid
chronoDateTime<Utc> as Timestamp (milliseconds), TimeDelta as Duration
timeOffsetDateTime and UtcDateTime as Timestamp (normalised to UTC), time::Duration as Duration
rust_decimalrust_decimal::Decimal as Decimal
bytesbytes::Bytes as Bytes

Precision is documented, not hidden. A Timestamp is milliseconds, and a finer value rounds toward negative infinity on its way out. A Duration is never negative, so a negative TimeDelta encodes as zero. DateTime<Local> and DateTime<FixedOffset> are E0001: convert to Utc, offsets do not cross. Spell a type without its feature and the error names the feature to turn on.

The playground's ledger uses every shape on this page, and the contract scenario S31 checks them on Swift, Kotlin and TypeScript.

Edit this page on GitHub