Suprnova's #[derive(Data)] lets you describe an inbound request shape, an outbound response shape, and a TypeScript export in one struct.
Quick start
use Data;
use Field;
use Validate;
#[derive(Data)] generates:
Serialize(skipping#[data(input_only)]fields)Deserialize(rejecting#[data(output_only)]fields in the payload, defaulting them toT::default())FormRequestwithauthorize: trueby default - handlers can take the type directly as an extractorIntoInertiaData(theInertia::data(component, dto)dispatch path)- An
inventory::submit!registration for any#[data(allow_include)]fields
Add #[derive(Validate)] separately so #[validate(...)] attributes stay visible at the field call site.
Field attributes
| Attribute | Effect |
|---|---|
#[data(input_only)] |
Accepted on Deserialize, omitted from Serialize |
#[data(output_only)] |
Rejected on Deserialize (422), included in Serialize |
#[data(allow_include)] |
Field is ?include=-eligible. Default-deny: any ?include=foo request where foo isn't on the allowlist returns 400 |
#[data(lazy)] |
Field is a Prop resolved against the request's include-set; auto-registers as allow_include |
#[data(lazy(inertia))] |
Same as lazy, tagged for Inertia's partial-reload protocol |
#[data(lazy(deferred))] |
Tagged for Inertia's deferred-props protocol |
#[data(lazy(closure))] |
Always resolved on initial visit; lazy on partial reloads |
#[data(lazy(when_loaded))] |
Resolved only if the source entity has the relation preloaded |
#[data(from_route_param)] |
Field value comes from a path capture (e.g. /users/{id}). Default key = field name; pass #[data(from_route_param("id"))] to override |
Struct attributes
| Attribute | Effect |
|---|---|
#[data(auto_lazy)] |
Every Prop-typed field is implicitly #[data(lazy)] |
#[data(authorize = "path::to::fn")] |
Route the generated FormRequest::authorize to a free function with signature fn(req: &Request) -> bool. The body parser, validator, Precognition support, and route-param injection still come from the derive |
#[data(after_validation = "path::to::fn")] |
Route the generated FormRequest::after_validation to fn(dto: &Self) -> Result<(), ValidationErrors>, where validate! rules run. See Validation rules and database checks |
#[data(after_validation_async = "path::to::fn")] |
Route FormRequest::after_validation_async to async fn(dto: &Self) -> Result<(), ValidationErrors>, where Exists, Unique and other async rules run |
#[data(allow_unknown_fields)] |
Accept payload keys that don't match any struct field. The default is strict: an unrecognised key fails the deserialize with serde::de::Error::unknown_field(..) and surfaces as a 422 through FormRequest. Opt into permissive only for response DTOs that read forward-compatible third-party payloads |
The earlier #[data(custom_authorize)] flag - which suppressed the whole FormRequest impl and forced you to reimplement body parsing, validation, and Precognition by hand - is gone. The macro emits a migration error if you try to use it. Use #[data(authorize = "fn")] instead.
Field names and serde attributes
A Data Object writes its own Serialize and Deserialize, and it honors serde's naming attributes in them:
| Attribute | Effect |
|---|---|
#[serde(rename_all = "camelCase")] on the struct |
Every field's key follows the rule. Serde's rules are all accepted: lowercase, UPPERCASE, PascalCase, camelCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, SCREAMING-KEBAB-CASE |
#[serde(rename = "id")] on a field |
The field's key, winning over rename_all |
rename(serialize = "..", deserialize = ".."), rename_all(serialize = "..", deserialize = "..") |
A different name in the output than in the input |
#[serde(skip)] |
The field is neither sent nor read; it takes its Default |
#[serde(skip_serializing)] / #[serde(skip_deserializing)] |
Left out of the output, or out of the input |
The name is used everywhere the field meets the client:
- Output (the serialize name): the JSON body, Inertia props and partial reloads, the
?include=allowlist of a lazy prop, and JSON:API attribute, relationship and include names. - Input (the deserialize name): the request body, and so the strict unknown-field check, a required field's "missing field" error, and the key a route parameter is injected under. The route parameter itself keeps its own name, the Rust field name unless
from_route_param("..")names one. - Validation errors are keyed by the input name, so the error bag, the page and
Precognition-Validate-Onlyall use the names the client sent; a Rust name inPrecognition-Validate-Onlymatches nothing. A localized message labels the name snake-cased, as Laravel's does:unitPricereads "unit price". Avalidate!row or a hook names a field by its Rust name, asvalidate!does; the error is keyed by the input name. A key that is already an input name passes through, unless it is also another field's Rust name. - Nested objects are renamed at their own level when their type registers its names: a type that derives
Data,FormRequestor#[request]does, and a plain#[derive(Deserialize, Validate)]struct does with#[derive(suprnova::InputNames)]. Without it, the nested part of the key keeps Rust names (lineItems.1.unit_price); so does a generic nested type. A field serdeflattens is reported under its own field name, a key the input does not have. A field the input cannot set (output_only,skip_deserializing) keeps its Rust name in an error key. With serde'salias, errors use the field's main name.
A raw identifier loses its r#, as it does with serde: pub r#type: String is sent as type.
Any other serde attribute on a Data Object fails to compile with an error that lists the supported ones: the derive would otherwise leave it silently unapplied. Two fields under the same key fail to compile too.
A #[derive(FormRequest)] or #[request] struct is deserialized by serde's own derive, which reads all of serde's attributes. Its validation errors are keyed by the input names in the same way.
Validation rules and database checks
#[validate(...)] attributes cover the per-field checks. Everything else
in Validation - validate! rows, cross-field rules like
After::new(DateBound::Field(..)) or ExcludeIf, and database rules like
Exists - runs in the FormRequest hooks. The derive writes a Data
Object's FormRequest impl, so you name the functions it calls:
use ;
use ;
use Validate;
async
The stages run in order and stop at the first that fails: the
#[validate(...)] attributes, then after_validation, then
after_validation_async. A malformed date therefore never reaches the
database. Precognition runs the same stages and reports the fields the
client asked about - asking about an array keeps the errors of its elements
(tag_ids keeps tag_ids.3). A Data Object with a from_route_param field
runs both hooks too.
A generic struct, or one with reference or lazy fields, gets no
FormRequest impl, so it would never run a hook; the derive refuses both
attributes on it.
Field<T> - Absent / Null / Value
For PATCH endpoints where "absent from payload" must be distinguished from "explicit null":
use Field;
match dto.bio
Field::Absent (default) is left out of a Data Object's output: the derive omits the key itself. In a plain struct that derives serde's Serialize, pair the field with #[serde(default, skip_serializing_if = "Field::is_absent")] for the same result; without skip_serializing_if, serde writes Absent as JSON null.
For three-way DB upserts: dto.bio.into_option_or_null() -> Option<Option<T>> maps Absent → None, Null → Some(None), Value(v) → Some(Some(v)). Use this when "don't touch" and "set to NULL" need to be distinct downstream.
Caveat:
Field<Option<T>>is lossy -Value(None)andNullboth serialize as JSONnulland deserialize back toNull. For nullable inner types, prefer a flatField<T>and letNullcarry the "clear it" signal.
?include= query string
The IncludeMiddleware parses the request's query string into a per-request RequestIncludeSet:
?include=foo,bar- resolve lazy fieldsfooandbar.?include[]=foo&include[]=bar- array form, same result.?exclude=,?only=,?except=- Laravel-Data API parity.
Composition with X-Inertia-Partial-Data (Inertia's partial-reload header): the include-set + per-DTO allowlist runs first for owner-tagged lazy fields, so a request for a disallowed field returns 400 even if partial-data would have filtered it out. Partial-data is applied after as a final "only" filter on the resolved props.
Register IncludeMiddleware globally - typically between session and authorization in the middleware stack:
SessionMiddleware → IncludeMiddleware → AuthMiddleware → handlers
Programmatic include/exclude/only/except
RequestIncludeSet mirrors Laravel-Data's IncludeableData contract with chainable builders. Handlers, tests, and middleware can construct or override a set without poking the public fields directly:
use RequestIncludeSet;
let set = default
.include
.exclude
.only
.except;
assert!; // on `only`, not in `except`
assert!;// `except` always wins
assert!; // request for the `author` relation
| Method | Effect | Laravel equivalent |
|---|---|---|
.include(fields) |
append to the include list (lazy fields to resolve) | Data::include(...$fields) |
.exclude(fields) |
append to the exclude list (fields to drop) | Data::exclude(...$fields) |
.only(fields) |
initialise or extend the only allowlist |
Data::only(...$fields) |
.except(fields) |
append to the except list (always-drop) | Data::except(...$fields) |
.include_when(cond, fields) |
append only when cond == true |
Data::includeWhen($field, $condition) |
.exclude_when(cond, fields) |
append only when cond == true |
Data::excludeWhen($field, $condition) |
.only_when(cond, fields) |
extend only only when cond == true |
Data::onlyWhen($field, $condition) |
.except_when(cond, fields) |
append only when cond == true |
Data::exceptWhen($field, $condition) |
.merge(other) |
union two sets (in-place layered overrides) | manual array_merge in PHP |
.includes(field) |
field (or field.path) in include list? |
relationLoaded() analogue |
.is_excluded(field) |
field in exclude list? |
reads exclude partial |
.is_excepted(field) |
field in except list? |
reads except partial |
.is_only_listed(field) |
field allowed by only (or only unset)? |
reads only partial |
.is_visible(field) |
full Laravel resolution order: except → exclude → only | resolveResource decision |
Builders take any IntoIterator<Item = impl Into<String>>, so arrays, vecs, and slices of &str/String all work. Strings are trimmed; empty entries are dropped (matching from_query).
Dot-paths in any list match the root segment when probed by bare name - include=["author.posts"] reports set.includes("author") == true, matching Laravel-Data's path resolution. The nested posts segment is consumed by IncludeTree::from_include_set for JSON:API compound documents.
Handler-side override: with_include_overrides
To layer programmatic overrides on top of what the request's query string already declared (without losing the request's set), use with_include_overrides:
use with_include_overrides;
async
The closure runs against a clone of the currently-bound set (or the empty default if no middleware has bound one). After the future completes, the original set is restored - this is a scoped override, not a mutation.
For tests, prefer scope_include_set(set, future) to install a fresh set without inheriting any ambient state.
Generic structs
use ;
The TypeScript extractor emits export interface Paginated<T> so frontend code can reuse the generic across instantiations.
The ?include= allowlist is keyed on the fully-qualified type path (concat!(module_path!(), "::", stringify!(Paginated))), not on type-parameter instantiations. Paginated<UserDto> and Paginated<ArticleDto> declared in the same module share one allowlist - allow_include names a field, and field names don't depend on type parameters. Two different DTOs named Paginated in different modules each get their own allowlist; their keys don't collide.
Note: FormRequest is suppressed for generic structs because its trait bounds (DeserializeOwned + Validate + Send) can't be verified without knowing concrete type params. Provide your own impl if you need to extract a generic Data struct from a request.
Route-parameter field injection
use Data;
use Validate;
For PATCH /users/{id} with body {"name": "Ada"}, the route-captured id is merged into the validated payload. The path always wins over a body-supplied value (prevents IDOR via body-tampering).
Bare #[data(from_route_param)] defaults to the field name. The macro classifies the field's last path segment at compile time and dispatches to a matching parser. Only the exact names listed below are recognised; everything else (including i8/i16/isize, Uuid, DateTime, custom newtypes) falls through to pass_string and lets the field's own Deserialize do the work.
| Field type | Parser |
|---|---|
i64 |
parse_i64 |
u64 |
parse_u64 |
i32 |
parse_i32 |
u32 |
parse_u32 |
i128 |
parse_i128 (validates then passes raw string through; field's Deserialize parses) |
u128 |
parse_u128 (same string-passthrough pattern) |
f64 |
parse_f64 (rejects non-finite values) |
f32 |
parse_f32 (rejects non-finite values) |
bool |
parse_bool (accepts only "true" / "false") |
| Anything else | pass_string - raw string handed to the field's own Deserialize |
Option<T> or Field<T> of any of the above |
Same parser as T; missing route param leaves the field absent |
Lazy props
use Data;
use Prop;
Explicit per-field flavor:
Every lazy flavor is gated the same way, lazy(deferred) included. A deferred field is opt-in twice over: ?include=lyrics puts it in scope for the request, and Inertia's deferred-props protocol decides which round trip carries it. A field the request never included is dropped whole - no value, no deferredProps announcement - so the client is never sent after something this request has no claim on. A field named by ?include= but missing from the allowlist returns 400 on the first visit, before X-Inertia-Partial-Data can quietly absorb the error.
Use Inertia::data(component, dto) to render - the derive generates an IntoInertiaData impl that consults the include-set and allowlist:
return data;
Note: lazy-bearing structs suppress Serialize, Deserialize, and FormRequest because Prop doesn't implement them. If a single endpoint needs both inbound parsing and lazy outbound, use two DTOs: one inbound (#[derive(Data, Validate)] plain) and one outbound (#[derive(Data)] with lazy fields).
when_loaded! - relation-loaded conditional lazy
Mirrors Laravel-Data's #[AutoWhenLoadedLazy]. The user's From<Entity> impl decides whether the relation was preloaded:
use ;
If the entity hasn't preloaded the named relation (per IsRelationLoaded::is_relation_loaded), when_loaded! returns Prop::absent() and the field is absent from the response.
SeaORM entities need a custom IsRelationLoaded impl that consults their loaded-relations state - there's no framework-supplied blanket impl because SeaORM's ModelTrait doesn't carry per-instance relation-loaded state (loaded relations live on query results, not the model struct itself).
TypeScript export
suprnova generate-types emits TypeScript definitions for every #[derive(Data)] (and legacy #[derive(InertiaProps)]) struct. Behavior:
Field<T>→field?: T | null- chrono's
DateTime,NaiveDate,NaiveDateTimeandNaiveTime→string, the ISO 8601 text they serialize as Prop→field?: T(the lazy may-be-absent semantic; the?carries it, the type itself is plain)#[data(input_only)]→ excluded from output type#[data(output_only)]→ excluded from input type- Generic struct → TypeScript generic interface (
export interface Paginated<T>) - When ANY field has
input_only/output_only/lazy, or the input and the output name a field differently or skip it in one direction only, two interfaces are emitted:<Name>(output) and<Name>Input(input) - A plain struct a prop reaches, one that derives serde's
Serialize, follows serde's attributes:#[serde(skip)]andskip_serializingleave the field out,skip_serializing_ifmakes it optional (field?: T), andrenameandrename_all(or theirserialize = ...forms) name the key. Other serde attributes, such asflattenandtransparent, are not read. A key that is not an identifier, such asdisplay-name, is quoted #[derive(Data)]and#[derive(InertiaProps)]structs follow the serde attributes they honor (see Field names and serde attributes):<Name>declares the serialize names,<Name>Inputthe deserialize names
Generated types never leak Rust-only types (Prop<...> won't appear in the output .d.ts).
Scaffolding
Emits a #[derive(Data, Validate)] skeleton instead of the legacy #[derive(InertiaProps)] template.
Next
- Validation -
#[derive(Validate)], async validators, and howFormRequestcalls into them - Requests - the request extractor surface that
FormRequestplugs into - Inertia Responses - the
Inertia::datapath and how lazy props become partial-reload-eligible - Eloquent Resources -
#[derive(Data)]with#[json_resource]for JSON:API outputs (sibling ofDatafor serialization-only payloads) - Error Model - how
unknown_fieldrejection becomes a 422 and howFormRequestfailures travel back asValidationErrors
