Compile-Time Required Field Verification for create!
Problem
When a user omits a required field from a create! invocation, the error only
surfaces at runtime as a database NOT NULL constraint violation. We want a
compile-time error that names the missing field.
#![allow(unused)]
fn main() {
#[derive(Model)]
struct User {
#[key]
#[auto]
id: Id<User>,
name: String, // required
email: String, // required
bio: Option<String>, // optional (nullable)
#[default(0)]
login_count: i64, // optional (has default)
}
// Should produce a compile error naming `email`
toasty::create!(User, { name: "Carl" }).exec(&db).await;
}
Design
Generate a hidden ZST verification chain alongside each model. The create!
macro expands to call the verifier in addition to the real builder. The verifier
uses typestate to track which required fields have been set and
#[diagnostic::on_unimplemented] to produce per-field error messages. The real
builder is unchanged.
What makes a field “required”
A field requires explicit user input on create unless ANY of these hold:
- The type is
Option<T>(nullable) - The field has
#[auto] - The field has
#[default(...)] - The field has
#[update(...)](applied as default on create) - The field is a
HasManyorHasOnerelation (populated separately)
BelongsTo fields are required if their target is non-nullable (e.g.,
BelongsTo<User> is required, BelongsTo<Option<User>> is not). This matches
the existing nullable detection via <T as Relation>::nullable().
Generated code
For a model with required fields name and email:
#![allow(unused)]
fn main() {
// ---- Marker types (defined once in toasty crate) ----
pub struct Set;
pub struct NotSet;
// ---- Generated by #[derive(Model)] on User ----
// One trait per required field with a custom diagnostic
#[doc(hidden)]
#[diagnostic::on_unimplemented(
message = "cannot create `User`: required field `name` is not set",
label = "call `.name(...)` before `.exec()`"
)]
pub trait __user_create_has_name {}
impl __user_create_has_name for Set {}
#[doc(hidden)]
#[diagnostic::on_unimplemented(
message = "cannot create `User`: required field `email` is not set",
label = "call `.email(...)` before `.exec()`"
)]
pub trait __user_create_has_email {}
impl __user_create_has_email for Set {}
// Verifier: all ZSTs, optimized away entirely
#[doc(hidden)]
pub struct __UserCreateVerify<Name = NotSet, Email = NotSet>(
::std::marker::PhantomData<(Name, Email)>,
);
impl __UserCreateVerify {
pub fn new() -> Self {
__UserCreateVerify(::std::marker::PhantomData)
}
}
impl<Name, Email> __UserCreateVerify<Name, Email> {
// Required field: transitions type param to Set
pub fn name(self) -> __UserCreateVerify<Set, Email> {
__UserCreateVerify(::std::marker::PhantomData)
}
pub fn email(self) -> __UserCreateVerify<Name, Set> {
__UserCreateVerify(::std::marker::PhantomData)
}
// Optional fields: no type transition
pub fn bio(self) -> Self { self }
pub fn login_count(self) -> Self { self }
// Relation fields (with_ variants used by create! for closures)
pub fn todos(self) -> Self { self }
pub fn with_todos(self) -> Self { self }
}
// check() only compiles when all required traits are satisfied
impl<Name, Email> __UserCreateVerify<Name, Email>
where
Name: __user_create_has_name,
Email: __user_create_has_email,
{
pub fn check(self) {}
}
// Entry point on the model type — resolves through aliases
impl User {
#[doc(hidden)]
pub fn __verify_create() -> __UserCreateVerify {
__UserCreateVerify::new()
}
}
}
create! macro expansion
The create! macro emits the verification chain before the real builder. The
verifier methods mirror the builder methods but take no arguments.
#![allow(unused)]
fn main() {
// Input:
toasty::create!(User, { name: "Carl", bio: "hello" })
// Expands to:
{
// Compile-time verification (all ZST, erased entirely)
User::__verify_create().name().bio().check();
// Real builder (unchanged)
User::create().name("Carl").bio("hello")
}
}
For type aliases (type Foo = User), Foo::__verify_create() resolves through
the type system to User::__verify_create() — no naming conventions needed.
Error messages
Missing one field:
error[E0277]: cannot create `User`: required field `email` is not set
--> src/main.rs:5:42
|
5 | create!(User, { name: "Carl" }).exec(&db).await;
| ^^^^ call `.email(...)` before `.exec()`
Missing multiple fields (Rust reports all unsatisfied bounds):
error[E0277]: cannot create `User`: required field `name` is not set
--> src/main.rs:5:24
|
5 | create!(User, {}).exec(&db).await;
| ^^^^ call `.name(...)` before `.exec()`
error[E0277]: cannot create `User`: required field `email` is not set
--> src/main.rs:5:24
|
5 | create!(User, {}).exec(&db).await;
| ^^^^ call `.email(...)` before `.exec()`
Scoped and batch create
For scoped creation (create!(user.todos(), { ... })), the create! macro
cannot call __verify_create() on the scope expression. Verification only
applies to the type-target form. This is acceptable: scoped creation already
implies certain fields are set by the relation.
For batch creation (create!(User, [{ ... }, { ... }])), each item in the list
gets its own verification chain.
#![allow(unused)]
fn main() {
// Input:
toasty::create!(User, [{ name: "Carl", email: "a@b.com" }, { name: "Bob", email: "b@c.com" }])
// Expands to:
{
User::__verify_create().name().email().check();
User::__verify_create().name().email().check();
User::create_many()
.with_item(|b| { let b = b.name("Carl").email("a@b.com"); b })
.with_item(|b| { let b = b.name("Bob").email("b@c.com"); b })
}
}
Nested creation (closures)
The create! macro generates .with_field(|b| { ... }) for nested struct
bodies. The verifier mirrors this with a no-arg .with_field() method that
returns Self (identity for relation fields).
#![allow(unused)]
fn main() {
// Input:
toasty::create!(User, { name: "Carl", email: "a@b.com", todos: [{ title: "buy milk" }] })
// Verification chain:
User::__verify_create().name().email().with_todos().check();
}
Nested model verification (e.g., verifying Todo’s required fields within the
closure) is not covered in this design. The nested model’s builder will catch
missing fields at the database level as it does today.
Implementation Plan
Step 1: Add marker types to toasty crate
Add Set and NotSet ZSTs to toasty::codegen_support (the module re-exported
for generated code).
File: crates/toasty/src/codegen_support.rs (or equivalent)
Step 2: Add is_required_on_create helper to codegen field
Add a method to Field in toasty-codegen that returns whether a field is
required for creation. This centralizes the logic:
#![allow(unused)]
fn main() {
impl Field {
pub fn is_required_on_create(&self) -> bool {
// Relations: only BelongsTo can be required
match &self.ty {
FieldTy::HasMany(_) | FieldTy::HasOne(_) => return false,
FieldTy::BelongsTo(rel) => return !rel.nullable,
FieldTy::Primitive(_) => {}
}
// Skip auto, default, update fields
if self.attrs.auto.is_some() {
return false;
}
if self.attrs.default_expr.is_some() || self.attrs.update_expr.is_some() {
return false;
}
// Check if the Rust type is Option<T>
// (For non-serialized fields, Primitive::NULLABLE handles this at
// runtime, but we need a syntactic check at codegen time.)
if let FieldTy::Primitive(ty) = &self.ty {
if is_option_type(ty) {
return false;
}
}
true
}
}
}
The is_option_type helper already exists in the codebase (used by serialize
field codegen). Extract it to a shared location if not already shared.
Step 3: Generate verifier in expand/create.rs
Add a new method expand_create_verifier to Expand that generates:
- One
__model_create_has_{field}trait per required field with#[diagnostic::on_unimplemented] - The
__ModelCreateVerifystruct with type params for required fields new(), field methods (required → type transition, optional → identity), andcheck()with trait bounds- The
__verify_create()associated function on the model impl
Call expand_create_verifier() from the model’s root expansion alongside
expand_create_builder().
Step 4: Update create! macro expansion
In crates/toasty-macros/src/create/expand.rs, modify the expand function to
emit the verification chain before the builder chain.
For Target::Type with CreateItem::Single:
#![allow(unused)]
fn main() {
// Verification chain: Type::__verify_create().field1().field2().check();
// Builder chain: Type::create().field1(val1).field2(val2)
}
For Target::Type with CreateItem::List, emit one verification chain per
item.
For Target::Scope, emit only the builder chain (no verification).
The verification field calls mirror the builder field calls but drop the
arguments. For CreateItem::Single, each field becomes .field_name(). For
CreateItem::List and nested structs, the with_* closure is replaced by a
simple .with_field_name() call.
Step 5: Tests
Add compile-fail tests that verify:
- Missing a single required field → error naming the field
- Missing multiple required fields → errors naming each field
- Optional fields can be omitted without error
#[auto]fields can be omitted without error#[default]fields can be omitted without error#[update]fields can be omitted without error- All fields provided → compiles successfully
- Type aliases work (
type Foo = User; create!(Foo, { ... }))
Limitations
-
Scope targets:
create!(user.todos(), { ... })does not get verification. The scope expression is not a type path, so we cannot call__verify_create()on it. -
Nested models: Required fields on nested models (inside closures) are not verified by this mechanism. They continue to rely on database constraint errors.
-
Direct builder API: Users who call
User::create().name("Carl").exec()without thecreate!macro do not get verification. The public builder is unchanged. This is intentional — the macro is the recommended API, and changing the builder’s type signature would be a larger change. -
diagnostic::on_unimplementedsupport: This attribute is stable since Rust 1.78. The custommessageandlabelfields are respected byrustc. Third-party tools (rust-analyzer, older compilers) may show a generic trait bound error instead of the custom message.
Files Modified
| File | Change |
|---|---|
crates/toasty/src/codegen_support.rs | Add Set, NotSet marker types |
crates/toasty-codegen/src/schema/field.rs | Add is_required_on_create() method |
crates/toasty-codegen/src/expand/create.rs | Add expand_create_verifier() |
crates/toasty-codegen/src/expand/mod.rs | Call expand_create_verifier() from root expansion |
crates/toasty-macros/src/create/expand.rs | Emit verification chain in expand() |