How a component's props become a Rust struct, and how to build one.
Props are declared by type in the component: `defineProps<{ … }>()`, an interface, or a type alias,
optionally with `withDefaults` or destructured defaults. The compiler writes one `Props` struct with
a public field per prop, and a struct for every interface or object type alias the props use.
# Types
| `string` | `Cow<'a, str>` | `impl Into<Cow<'a, str>>`: a `&str`, a `String`, a `Cow` |
| `number` | `i64` | `i64` |
| `Float` (from `ferrovue/types`) | `f64` | `f64` |
| `boolean` | `bool` | `bool` |
| `"sm" \| "md" \| "lg"` | `Cow<'a, str>` | as `string` |
| `enum Tone { Calm = "calm" }`, `enum Rank { Low, High }` | `Cow<'a, str>`, `i64`: the type of its members' values | as `string`, as `number` |
| `string[]`, `Array<string>`, `readonly string[]` | `Vec<Cow<'a, str>>` | `impl IntoIterator<Item = impl Into<Cow<'a, str>>>` |
| `number[]`, `Row[]`, … | `Vec<i64>`, `Vec<Row<'a>>`, … | the `Vec` |
| `interface Row { … }`, `type Row = { … }` | a struct `Row<'a>` beside `Props`, or in `types.rs` when imported from a `.ts` file | the struct |
| `Record<string, T>`, `{ [key: string]: T }` | [`ferrovue::Record<'a, T>`](crate::Record) | the `Record`, collected from pairs. See [Dictionaries](#dictionaries) |
| `Props` imported from another component | `super::child::Props<'a>` | the struct |
| `TrustedHtml` (from `ferrovue/types`) | the type configured as `trustedHtml` | that type. See [`escaping`](crate::guide::escaping) |
| `Date`, a component's own prop | [`ferrovue::JsDate`](crate::JsDate), or the type configured as `dateType` | that type. See [`dates_and_prices`](crate::guide::dates_and_prices#date-props) |
| `x?: T`, `x: T \| undefined` | `Option<T>` | a setter, `.x(value)` |
| `x: T \| null` | `Option<T>`, written as `null` | a setter, `.x(value)` |
| `T`, of `<script setup generic="T extends Row">` | the type of its constraint, `Row<'a>` | as the constraint |
A `number` is an integer on the server, which is what most props are: counts, ids, indexes. Declare
a prop that may hold a fraction as `Float`. [`numbers`](crate::guide::numbers) explains how both are
computed and written.
A string-literal union is a plain string in Rust: the TypeScript compiler checks the client's
values, but nothing checks what Rust passes. A value outside the union renders as given.
Strings are `Cow`s so that props can borrow from what the request already holds, and so that a
parent handing an object to a child copies only pointers.
A parent's template names a child's props as Vue matches them, `camelCase` or `kebab-case`
(`:item-count` sets `itemCount`). Anything else it passes is not a field of `Props`: it is an
attribute, which falls through to the child's root
([`generated_code`](crate::guide::generated_code#fallthrough-attributes)).
# Optional props and defaults
An optional prop is an `Option`, `None` unless its setter is called. A default from `withDefaults`
or a destructured default (`const { size = "md" } = defineProps<…>()`) is applied while rendering, so
the field stays `None` and the island's JSON leaves it out, as Vue's would. An optional `boolean`
that is absent renders as `false`, which is the cast Vue applies.
# Nullable props
A prop declared `x: T | null` is an `Option` too, `None` unless its setter is called, and `None` is
`null`: the island's JSON writes `"x":null`, where an optional prop's `None` is left out, so the
client hydrates with the `null` the server rendered. Data from a JSON API or a database keeps its
shape, `deletedAt: string | null` included, and so do the fields of interfaces and the state of
Pinia stores (`ref<string | null>(null)`).
The template tests it as TypeScript narrows it: `x !== null`, `x === null`, `x != null`,
`x == null` and `x` alone. `??`, `?.`, `{{ x }}`, which writes nothing for `null`, and an attribute
bound to it, which is left out, behave as for an optional value. `x === undefined` is refused, since
a nullable prop is never `undefined`, and so is `x === null` of an optional one.
An `Option` holds one kind of nothing, so a value that may be both is refused where the difference
would show: `T | null | undefined` and `x?: T | null` as types, a default for a nullable prop (Vue
gives it only when the prop is absent), and `=== null` or `=== undefined` of `a?.b` where `b` is
nullable (use `== null`). A parent passes a nullable prop always, `null` for none, and never hands
an optional value to it, or a nullable one to an optional prop, without `?? null` or
`?? undefined`.
# `typeof`
Every value a template reads has a type the compiler knows, so `typeof x` is worked out when
compiling: `"string"` for a string, `"number"` for a `number` or a `Float`, `"boolean"`, and
`"object"` for an object, a list, a record, a `Date` and `null`. Of an optional value it is the
value's type when present and `"undefined"` when absent, so `typeof x === "string"` and
`typeof x !== "undefined"` test whether it is present and narrow it as `x !== undefined` does
(`v-if="typeof label === 'string'"`, then `label.length` inside). Of a nullable one, absent is
`"object"`, as `typeof null` is in JavaScript. A comparison with any other name is `false`. Where
the answer depends on whether a value that may be either `null` or `undefined` is which,
`typeof (on ? a : null) === "undefined"`, it is refused
([FV0722](crate::guide::error_codes#fv0722)), as is `typeof` of a value from a plugin whose type is
its own; `typeof route.query.q === "string"` is the router's own
([`routing`](crate::guide::routing)).
# Optional chains and list items
`a?.b` is `undefined` when `a` is absent, and so is a chain that goes on past it as a whole
(`a?.b.c`, `s?.trim().length`, `tags?.at(-1)?.toUpperCase()`): the generated code maps the rest of
the chain over the `Option`, and every property and method the value has when present may follow.
A list's item, `list[i]` with an integer `i` or `list.at(i)`, which counts from the end when `i` is
negative, is an optional value too, `undefined` outside the list: `v-if="rows[0]"` narrows it, and
`??`, `?.` and `{{ }}` take it as they take an optional prop. An index that is a `Float`, which may
hold a fraction, a string or a value that may be absent is refused.
# Building props
`Props::new` takes the required props, in the order they are declared; each optional prop has a
setter that consumes and returns the struct. The fields are public, so a struct literal works too.
```rust
# mod data_list {
# // @generated by ferrovue from components/DataList.vue. Do not edit: change the `.vue` file and run
# // `ferrovue`.
#
# use std::borrow::Cow;
#
# use ferrovue as fv;
#
# /// The component's name, as `data-island` carries it.
# pub const NAME: &str = "DataList";
#
# /// `Row` in `DataList.vue`.
# #[derive(Debug, Clone, serde::Serialize)]
# #[cfg_attr(test, derive(serde::Deserialize))]
# pub struct Row<'a> {
# #[serde(rename = "id")]
# pub id: i64,
# #[serde(rename = "label")]
# pub label: Cow<'a, str>,
# #[serde(rename = "tags")]
# pub tags: Vec<Cow<'a, str>>,
# }
#
# impl<'a> Row<'a> {
# /// Row with its required fields.
# pub fn new(id: i64, label: impl Into<Cow<'a, str>>, tags: impl IntoIterator<Item = impl Into<Cow<'a, str>>>) -> Self {
# Row { id, label: label.into(), tags: tags.into_iter().map(Into::into).collect() }
# }
#
# }
#
#
# /// The props `DataList.vue` declares.
# #[derive(Debug, Clone, serde::Serialize)]
# #[cfg_attr(test, derive(serde::Deserialize))]
# pub struct Props<'a> {
# #[serde(rename = "rows")]
# pub rows: Vec<Row<'a>>,
# #[serde(rename = "title")]
# pub title: Cow<'a, str>,
# #[serde(rename = "note", default, skip_serializing_if = "Option::is_none")]
# pub note: Option<Cow<'a, str>>,
# }
#
# impl<'a> Props<'a> {
# /// Props with its required fields, every optional one absent.
# pub fn new(rows: Vec<Row<'a>>, title: impl Into<Cow<'a, str>>) -> Self {
# Props { rows, title: title.into(), note: None }
# }
#
# /// Set `note`, which is absent otherwise.
# pub fn note(mut self, note: impl Into<Cow<'a, str>>) -> Self {
# self.note = Some(note.into());
# self
# }
# }
#
#
# /// The props `<slot name="header">` passes the content a parent gives it, borrowed for the render.
# pub struct HeaderSlotProps<'v> {
# pub title: &'v str,
# pub note: Option<&'v str>,
# }
#
# /// A parent's content for `<slot name="header">`, given its props: returns whether it wrote anything but comments.
# pub type HeaderSlot<'s> = dyn for<'v> Fn(&mut String, &HeaderSlotProps<'v>) -> bool + 's;
#
# /// The props `<slot name="row">` passes the content a parent gives it, borrowed for the render.
# pub struct RowSlotProps<'v> {
# pub row: &'v Row<'v>,
# pub index: i64,
# pub label: &'v str,
# pub tags: &'v [Cow<'v, str>],
# pub first: bool,
# }
#
# /// A parent's content for `<slot name="row">`, given its props: returns whether it wrote anything but comments.
# pub type RowSlot<'s> = dyn for<'v> Fn(&mut String, &RowSlotProps<'v>) -> bool + 's;
#
# /// The props `<slot name="empty">` passes the content a parent gives it, borrowed for the render.
# pub struct EmptySlotProps<'v> {
# pub title: &'v str,
# }
#
# /// A parent's content for `<slot name="empty">`, given its props: returns whether it wrote anything but comments.
# pub type EmptySlot<'s> = dyn for<'v> Fn(&mut String, &EmptySlotProps<'v>) -> bool + 's;
#
# /// The props `<slot>` passes the content a parent gives it, borrowed for the render.
# pub struct DefaultSlotProps<'v> {
# pub count: i64,
# pub title: &'v str,
# }
#
# /// A parent's content for `<slot>`, given its props: returns whether it wrote anything but comments.
# pub type DefaultSlot<'s> = dyn for<'v> Fn(&mut String, &DefaultSlotProps<'v>) -> bool + 's;
#
# /// What a parent puts in the slots `DataList.vue` renders.
# #[derive(Clone, Copy, Default)]
# pub struct Slots<'s> {
# /// `<slot name="header">`
# pub header: Option<&'s HeaderSlot<'s>>,
# /// `<slot name="row">`
# pub row: Option<&'s RowSlot<'s>>,
# /// `<slot name="empty">`
# pub empty: Option<&'s EmptySlot<'s>>,
# /// `<slot>`
# pub default: Option<&'s DefaultSlot<'s>>,
# }
#
# /// Write the component's server render into `out`.
# pub fn render(out: &mut String, props: &Props<'_>, fv_slots: Slots<'_>) {
# out.reserve(96 + 17 * props.rows.len() + props.rows.iter().map(|v| v.label.len() + v.tags.iter().map(|v| v.len()).sum::<usize>()).sum::<usize>() + props.title.len() + props.note.as_deref().map_or(0, str::len));
# out.push_str("<section><header>");
# fv::scoped_slot_into(out, fv_slots.header, &HeaderSlotProps { title: &props.title, note: props.note.as_deref() }, Some(&mut |out: &mut String| {
# fv::escape_into(out, &props.title);
# }));
# out.push_str("</header>");
# if props.rows.len() as i64 != 0 {
# out.push_str("<ul><!--[-->");
# for (i, r_ref) in props.rows.iter().enumerate() {
# let r = r_ref;
# let i = i as i64;
# out.push_str("<li>");
# fv::scoped_slot_into(out, fv_slots.row, &RowSlotProps { row: r, index: i, label: &r.label, tags: &r.tags, first: i as f64 == 0.0 }, Some(&mut |out: &mut String| {
# fv::push_int(out, i);
# out.push_str(". ");
# fv::escape_into(out, &r.label);
# }));
# out.push_str("</li>");
# }
# out.push_str("<!--]--></ul>");
# } else {
# out.push_str("<p>");
# fv::scoped_slot_into(out, fv_slots.empty, &EmptySlotProps { title: &props.title }, Some(&mut |out: &mut String| {
# out.push_str("no rows in ");
# fv::escape_into(out, &props.title);
# }));
# out.push_str("</p>");
# }
# out.push_str("<footer>");
# fv::scoped_slot_into(out, fv_slots.default, &DefaultSlotProps { count: props.rows.len() as i64, title: &props.title }, None);
# out.push_str("</footer></section>");
# }
#
# /// The component's markup, for a maud page that shows it.
# pub fn html<'p, 'a>(props: &'p Props<'a>, fv_slots: Slots<'p>) -> fv::Html<'p, Props<'a>, impl Fn(&mut String, &Props<'a>) + 'p> {
# fv::Html::markup(props, move |out: &mut String, props: &Props<'a>| render(out, props, fv_slots))
# }
# }
// DataList.vue:
// export interface Row { id: number; label: string; tags: string[] }
// defineProps<{ rows: Row[]; title: string; note?: string }>();
use std::borrow::Cow;
let owned = String::from("from the database");
let rows = vec![
data_list::Row::new(1, "first", ["new", "rust"]),
data_list::Row::new(2, owned.as_str(), Vec::<String>::new()),
];
let props = data_list::Props::new(rows, "Rows").note(format!("{} rows", 2));
// What an island would carry: Vue's prop names, absent optionals left out.
assert_eq!(
serde_json::to_string(&props).unwrap(),
r#"{"rows":[{"id":1,"label":"first","tags":["new","rust"]},{"id":2,"label":"from the database","tags":[]}],"title":"Rows","note":"2 rows"}"#
);
// The same props as a struct literal.
let literal = data_list::Props {
rows: Vec::new(),
title: Cow::Borrowed("Rows"),
note: None,
};
assert!(literal.note.is_none());
```
An app that builds every struct as a literal can leave the builders out with `"builders": false` in
`ferrovue.config.json`: the `impl` with `new` and the setters is then not written for any props
struct, shared type or store state, code that rustc would otherwise compile whether it is called or
not. The structs keep their public fields and their derives, `Default` included, so a struct with no
required field still takes `..Default::default()`. The key applies to the whole project: a shared
type belongs to no component, and a `.vue` file has no place Vue reads that could mark it.
# Dictionaries
A `Record<string, T>` prop, or one declared `{ [key: string]: T }`, is a [`Record`](crate::Record):
a JavaScript object used as a dictionary. It is built from pairs, or read from JSON, and holds its
keys in the order a JavaScript object does: keys that are array indices (`"0"` to `"4294967294"`)
first, in numeric order, then the others in the order they were added. A key given twice keeps its
first place and its last value, as `JSON.parse` keeps it. That is the order `v-for`,
`Object.keys`, `Object.values` and `Object.entries` walk, and the order the record is written back
as JSON, which the browser reads back the same.
```vue
<script setup lang="ts">
defineProps<{ scores: Record<string, number> }>();
</script>
<template>
<ol><li v-for="(score, name, i) in scores">{{ i + 1 }}. {{ name }}: {{ score }}</li></ol>
</template>
```
```rust
# mod scores {
# // @generated by ferrovue from client/components/Scores.vue. Do not edit: change the `.vue` file and run
# // `ferrovue`.
#
# use ferrovue as fv;
#
# /// The component's name, as `data-island` carries it.
# pub const NAME: &str = "Scores";
#
# /// The props `Scores.vue` declares.
# #[derive(Debug, Clone, serde::Serialize)]
# #[cfg_attr(test, derive(serde::Deserialize))]
# pub struct Props<'a> {
# #[serde(rename = "scores")]
# pub scores: ferrovue::Record<'a, i64>,
# }
#
# impl<'a> Props<'a> {
# /// Props with its required fields.
# pub fn new(scores: ferrovue::Record<'a, i64>) -> Self {
# Props { scores }
# }
#
# }
#
#
/// Write the component's server render into `out`.
pub fn render(out: &mut String, props: &Props<'_>) {
out.reserve(25 + 25 * props.scores.len() + props.scores.iter().map(|(k, _)| k.len()).sum::<usize>());
out.push_str("<ol><!--[-->");
for (i, (name, score_ref)) in props.scores.iter().enumerate() {
let score = *score_ref;
let i = i as i64;
out.push_str("<li>");
fv::push_int(out, (i as f64 + 1.0) as i64);
out.push_str(". ");
fv::escape_into(out, name);
out.push_str(": ");
fv::push_int(out, score);
out.push_str("</li>");
}
out.push_str("<!--]--></ol>");
}
#
# /// The component's markup, for a maud page that shows it without hydrating it.
# pub fn html<'p, 'a>(props: &'p Props<'a>) -> fv::Html<'p, Props<'a>> {
# fv::Html::markup(props, render)
# }
#
# /// The component as an island the client hydrates.
# pub fn island<'p, 'a>(props: &'p Props<'a>) -> fv::Html<'p, Props<'a>> {
# fv::Html::island(NAME, props, render)
# }
# }
use ferrovue::Record;
// "10" is an array index, so it comes first, as in the browser.
let scores: Record<'_, i64> = [("ada", 3), ("10", 1), ("grace", 2)].into_iter().collect();
let props = scores::Props::new(scores);
let mut out = String::new();
scores::render(&mut out, &props);
assert_eq!(out, "<ol><!--[--><li>1. 10: 1</li><li>2. ada: 3</li><li>3. grace: 2</li><!--]--></ol>");
assert_eq!(serde_json::to_string(&props).unwrap(), r#"{"scores":{"10":1,"ada":3,"grace":2}}"#);
```
A field read by name, `r.key` or `r[key]`, is refused, since it may be absent although TypeScript
says it is not; so are `Object.entries()` anywhere but as a `v-for`'s source, and dictionaries of
optional values.
# Shared types
An interface imported from a `.ts` file is written once, to `types.rs`, so that components passing
a value to one another agree on its type: `UserCard.vue` and `UserList.vue` importing `User` from
`types/models.ts` both use `super::types::User<'a>`. A type imported from another component's `.vue`
file is that component's struct (`super::data_list::Row<'a>`), and one imported from a store's file
is the struct in `stores.rs`.
A component's own interfaces and object type aliases, and those of the `.ts` files it imports, are
translated when the server render reaches them: through a component's props and `defineModel` (a
type parameter's constraint included), a provided or injected value, a `ref` or other binding the
server computes, and a constant list declared with an interface, together with the types of their
fields. The component's module and `types.rs` hold only these, and only these are checked. A type
nothing reaches is neither written nor refused, so a module a component imports for its functions
may declare types with no Rust counterpart (`type Row = Record<string, unknown>`), an interface only
an event handler uses may hold an `HTMLElement`, and an exported interface no component uses has no
struct.
A field whose type has no Rust counterpart is left out of its struct when only values the server
computes reach the type. With `interface Cat { id: string; items: HTMLElement[] }` and
`const cats = shallowRef<Cat[]>([])`, a template that reads `cats.length` or `cat.id` compiles, and
`Cat` is a struct with `id` alone. Reading `items` on the server, in the template or in a
`computed` the template reads, is refused where the field is declared, with its code. Props,
`defineModel` and the types their fields hold keep every field, since the props come from Rust and
are sent to the client, so such a field in a type a prop reaches is refused where it is declared.
A constant imported from a `.ts` file is evaluated when the component is compiled; see
[constants](#constants).
Each generated struct has the same `new` and setters as `Props` (none with `"builders": false`), and derives `Debug`, `Clone` and
`serde::Serialize`. `serde::Deserialize` is derived only under `cfg(test)`, for the conformance
helper: props go out to the client as JSON and never come back in. An object prop handed to a child
component is cloned; its strings are `Cow`s, so borrowed ones cost nothing to copy.
# Constants
A constant a component imports from a `.ts` file, or an `enum`, imported or declared in the
component, is evaluated when the component is compiled, as `types.rs` holds the shared types:
```ts
// components/catalog.ts
export const LABELS = { title: "Catalogue", empty: "Nothing yet" } as const;
export const SIZES = ["sm", "md", "lg"] as const;
export const SORTS = [
{ key: "name", label: "By name" },
{ key: "price", label: "By price" },
] as const;
export enum Tone { Calm = "calm", Loud = "loud" }
```
Strings, numbers, booleans, `null`, lists of them and objects of them read field by field
(`LABELS.save`, `Tone.Loud`, `Rank[5]`) are written where the template reads them:
`{{ LABELS.title }}` and `v-if="tone === Tone.Loud"` go into the page as the strings they are, and
`v-for="s in SIZES"` walks an array literal. A list of objects becomes a `const` in `types.rs`, with
a type for its items: the interface the constant is declared with (`SORTS: Sort[]`), or one named
after the constant, here `SortsItem`. A field that is `null` in some items is `T | null`.
```rust
# use std::borrow::Cow;
# pub struct SortsItem<'a> { pub key: Cow<'a, str>, pub label: Cow<'a, str> }
pub const SORTS: &[SortsItem<'static>] = &[
SortsItem { key: Cow::Borrowed("name"), label: Cow::Borrowed("By name") },
SortsItem { key: Cow::Borrowed("price"), label: Cow::Borrowed("By price") },
];
# assert_eq!(SORTS[1].label, "By price");
```
A constant is a literal, a list, an object, or another constant of the same file. One that is
anything else (`Date.now()`, a function) is refused where the template reads it, naming the file
and line it is declared on. So are an object constant read whole or by a key chosen at run time
(`LABELS[key]`), and `new` of anything but `Date` (`new Map()`).