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
/// 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 { .. }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 itval latest = newest(todos) // Todo?
val other = newest(notes) // Note?
val fresh = draft(Todo::class, "Untitled")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
/// 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>;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 emptyval todos = TodoSelection.create()
todos.toggle(todo) // todos.rows.value == listOf(todo)
val notes = NoteSelection.create()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
- Declare the alias next to the template. A hidden macro carries the template's signatures and expands where the alias stands, so the types they name must be in scope there. Next to the template this is invisible; in another module, a missing
useisrustc's "cannot find type in this scope", at the signature and in the alias's expansion. A store's instantiation also reads the store's fields and calls itsrestorehook, so in another module those must be visible there too: with private ones,rustcsays "fieldrowsof structSelectionis private" and "associated functionassembleis private", in the alias's expansion. In the template's module both questions go away. - The alias lives in the crate of its template, once per instantiation. A second alias for the same instantiation, or one in another crate, is E0070: the instantiation implements the boundary traits for the template's type, which only that crate may do.
- A store's struct comes first. The impl block must be below its struct in the same module; the macro says so in a name,
_undra_error_E0011_…, when it is not. - One block per type, for the type applied to its own parameters.
impl<T> Cache<Vec<T>>and a method with a list inside a generic block are E0074: an instantiation is one class, and a second list would multiply it. - Name an instantiation when a signature mentions one. A function that returns
Arc<Selection<T>>orPage<T>reaches the platforms as the alias's class or record, found by the compiler; spellingSelection<Todo>in a hand-written, non-generic signature is still E0002, and says which alias to declare.
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.
- Ids. A function's id comes from its schema name,
fnv1a32("fn.newest<Todo>"); an alias's from its own name. A raw call with an id that names no instantiation (newest<Draft>) is refused as a bad request and the core carries on,UndraCallError.refusedon the platforms. - The schema. One definition per instantiation; a function's carries a label (
newest,T = Todo) that the generators read to make overloads, and that is part of the schema's hash only where it is set, so every schema without generics keeps its hash.undra bindgen --checkandschema.jsonsee it like any other change. - Persistence and the testing kit. One restorer and one fingerprint per instantiated store, computed from its concrete signals; a recording is of one schema, so adding an instantiation records again, as any schema change does. Renaming an alias is removing a store type and adding one.
- Devtools list a call as
newest<Todo>and a store under its alias. - Two cores each hold the instantiations of the crates they link; nothing is shared at run time.
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.