Guides

Generic functions, objects and stores

Write the code once over a type parameter, and say which types the platforms get. A generic function becomes a set of overloads; a generic object or store becomes one ordinary class for each alias you declare.

Undra's callers are Swift, Kotlin and TypeScript, which cannot call a Rust generic: there is no T at run time to dispatch on. So the core lists the types it is generic over where it declares the code, the macro expands the ordinary function, method, object or store once for each, and the platforms see what you would have written by hand. Nothing is generic on the wire, in the schema or in the runtimes; the playground's selection module is the example this page follows, and the contract scenario S34 checks it on Swift, Kotlin and TypeScript.

Functions and methods#

List the types after generic(T = [..]). The function stays a plain generic Rust function, so the core's own code and tests call it as usual; each listed type is one more function across the boundary, named newest<Todo> in the schema, with an id of its own.

Show the Rust code and what each platform calls
rust
/// What the generic code needs of a row: plain Rust, never seen by the schema.
pub trait Row: SignalValue {
    fn drafted(serial: u64, title: String) -> Self;
    fn serial(&self) -> u64;
}

/// The newest of `rows`, or nothing for an empty list.
#[undra::api(generic(T = [Todo, Note]))]
pub fn newest<T: Row>(rows: Vec<T>) -> Option<T> {
    rows.into_iter().max_by_key(Row::serial)
}

/// A new row of the type the caller names.
#[undra::api(generic(T = [Todo, Note]))]
pub fn draft<T: Row>(title: String) -> T { .. }
swift
let latest = try newest(rows: todos)                  // Todo?
let other  = try newest(rows: notes)                  // Note?
let fresh  = try draft(Todo.self, title: "Untitled")  // the type is named: no argument says it
kotlin
val latest = newest(todos)                      // Todo?
val other  = newest(notes)                      // Note?
val fresh  = draft(Todo::class, "Untitled")
ts
const latest = await newest("Todo", todos);   // Todo | null
const other  = await newest("Note", notes);   // Note | null
const fresh  = await draft("Todo", "Untitled");

Each language gets the shape its own engineers write. Swift and Kotlin overload on the parameter's type, and take the type itself (Todo.self, Todo::class) when no parameter says which one is meant, as draft. TypeScript has no overloading on types, so every generic function takes the type's name as a leading string literal and is one overload set whose signatures the compiler checks; the literal is always there, so every generic function is called the same way. A type that is not listed is the compiler's error in all three, with the candidates beside it.

A method of an object or store takes the same list on the method, #[undra(generic(T = [Todo, Note]))]; each listed type is one method, overloaded in the generated class the same way. The documentation, async, streams, typed errors and ctx work as they do for any function, because each instantiation is one.

The list holds named value types: records, enums, newtypes. A scalar, a String, a container or an object in a list is E0072, because the platforms would have to invent a name for it. A function has one type parameter; two is E0072 too. A type that has no list at all is E0002: the callers are on the platforms, and the core must contain what they can call.

Objects and stores#

An object or a store over a type parameter is a template: it registers nothing, and each alias that names it is an ordinary object or store of that name. This is the shape of generic data types, extended to impl blocks and to a store's struct.

Show the Rust code and the stores it makes
rust
/// The rows the user has ticked.
#[undra::store(generic, restore = "Self::assemble")]
pub struct Selection<T> {
    #[undra(key = "id")]
    rows: Signal<Vec<T>>,
    count: Computed<u32>,
}

#[undra::api(store, generic)]
impl<T: Row> Selection<T> {
    pub fn new(ctx: Ctx) -> Self { .. }
    fn assemble(_ctx: Ctx, rows: Signal<Vec<T>>) -> Self { .. }
    /// Ticks `row`, or unticks it if it is ticked.
    pub fn toggle(&self, row: T) { .. }
}

/// The ticked to-dos.
#[undra::api] pub type TodoSelection = Selection<Todo>;
/// The ticked notes.
#[undra::api] pub type NoteSelection = Selection<Note>;
swift
let todos = try TodoSelection()
todos.toggle(todo)                 // todos.rows == [todo], todos.count == 1
let notes = try NoteSelection()    // a store of its own: notes.rows is still empty
kotlin
val todos = TodoSelection.create()
todos.toggle(todo)                 // todos.rows.value == listOf(todo)
val notes = NoteSelection.create()
ts
const todos = await TodoSelection.create();
await todos.toggle(todo);          // todos.rows.peek() equals [todo]
const notes = await NoteSelection.create();

TodoSelection and NoteSelection are two stores in every sense: two type ids, two handles, two sets of signals, two entries in a snapshot, and each ticks and restores on its own. Toggling a to-do is one keyed insert into the to-do selection's rows and nothing for the notes. A generic object that is not a store has no attribute on its struct, only #[undra::api(generic)] on its impl block and an alias.

The alias is where the rules are checked, as if you had written the store out: a type argument the position cannot hold is the error you would get there (a HashMap key that is not a valid key is E0006), and a keyed list over a row type without the key field is E0008 at the alias. A type parameter that stands in a signal needs the bound the signal needs, SignalValue (in the prelude), which the example's Row has as a supertrait.

Where to put what

What an instantiation is#

It is exactly a hand-written definition under another name, so everything that works for those works for it, and nothing was added to the wire, the runtimes or the generated classes of an object.

When it goes wrong#

The macro's errors are the usual four-part ones: E0072 for a list that is wrong (a parameter the function does not have, an empty list, a type listed twice, a type that is not a named value type, two parameters), E0074 for a generic block of the wrong shape, E0002 for a generic with no list, a generic constructor or a signature that spells an instantiation, and E0070 for a second or foreign alias. An error inside one instantiation says which one: "in newest<Todo>".

A call with a type that is not listed is the platform compiler's error, naming the overloads that exist: Swift's "no exact matches in call to global function", Kotlin's "none of the following candidates is applicable", TypeScript's "no overload matches this call". One caveat, from Swift 6.3: for an array literal of an unlisted type (newest(rows: [Draft(..)])) the compiler prints "failed to produce diagnostic for expression" instead of the message it prints for a variable. It is the compiler's wording, only in that case; the call is rejected either way.

An argument that does not say its type picks no overload, as with any overload set: newest(rows: []) and a nil are Swift's "ambiguous use of", and newest(emptyList()) is Kotlin's "cannot infer type". Write the type where the value is made, newest(rows: [Todo]()) or newest(emptyList<Todo>()) (in Swift, a declared result type, let latest: Todo? = try newest(rows: []), also picks it). TypeScript's leading literal always picks the overload, so newest("Todo", []) needs nothing.

In TypeScript the exported function is one overload set whose body switches on the literal, so a bundle that calls newest("Todo", ..) also carries the code and codecs of newest("Note", ..): tree-shaking keeps or drops a generic function whole, with every type it is listed for.

What is not there#

The table is on the types page, with what would lift each limit. In short: no open-ended T (every instantiation is listed), one type parameter per function, no generic queries, ports, callbacks or error enums, and no native generics, because the schema and the platforms' dispatch have no type parameters. A generic Page<T> that a function returns is still the named record of the data types.