Typed Component Properties
Summary
Section titled “Summary”Replace the stringly-typed property system — where components define schemas as Vec<PropertySchema>, receive values as HashMap<String, PropertyValue>, and construct bindings via bindings.insert("key".into(), bind_xxx(val)) — with a macro-driven, strongly typed system. A #[derive(ComponentProperties)] proc macro on a properties struct generates: (1) schema metadata from struct fields, (2) typed extraction from value maps, (3) typed binding construction for reducers, and (4) a partial-update struct for property mutations. A new TypedComponent trait provides a typed compile(&self, meta) method; a blanket impl wires it to the existing Component trait with zero changes to the compilation pipeline.
Motivation
Section titled “Motivation”1. Schema and usage are disconnected
Section titled “1. Schema and usage are disconnected”A component’s properties() method returns Vec<PropertySchema> with string keys. Its compile() method extracts values from HashMap<String, PropertyValue> using the same string keys via helper functions. Nothing connects the two — adding a property to properties() doesn’t produce a compile error if compile() forgets to read it, and a typo in a key string is a silent runtime bug.
// Schema says "stroke_width"PropertySchema { key: "stroke_width".into(), ... }
// Compile reads "stroke_widht" — no error, just returns Nonelet stroke_width = get_number(values, "stroke_widht").unwrap_or(2.0);2. Massive boilerplate in schema definitions
Section titled “2. Massive boilerplate in schema definitions”Each property requires ~7 lines of PropertySchema construction. ShapeComponent has 8 properties = 60+ lines of schema boilerplate that could be a 10-line struct definition.
3. Reducer binding construction is stringly-typed
Section titled “3. Reducer binding construction is stringly-typed”Whiteboard element creation and property updates use 60+ bindings.insert("key".into(), bind_xxx(val)) calls in whiteboard.rs. These duplicate the same string keys from the component’s schema with no compile-time connection. A key mismatch between the reducer and the component means the property silently doesn’t apply.
// Creating a shape: 10 lines of manual binding insertionbindings.insert("shape_type".into(), bind_string(st_str));bindings.insert("fill".into(), bind_string(fill_str));bindings.insert("color".into(), bind_string(&color.to_hex()));bindings.insert("stroke_color".into(), bind_string(&color.to_hex()));bindings.insert("stroke_width".into(), bind_number(stroke_width));bindings.insert("start_arrow".into(), bind_string(arrow_str(&start_arrow)));bindings.insert("end_arrow".into(), bind_string(arrow_str(&end_arrow)));bindings.insert("radius".into(), bind_number(0.0));bindings.insert("flipped".into(), bind_bool(flipped));4. Duplicated helpers across modalities
Section titled “4. Duplicated helpers across modalities”Both whiteboard and worksheet define identical helper functions (get_string, get_number, get_bool, get_select_value, get_color, get_i32, get_json_string_array) in separate helpers.rs files. These exist only because the property system is untyped.
5. Scale of the problem
Section titled “5. Scale of the problem”| Category | String-keyed sites | Files |
|---|---|---|
Schema definitions (properties()) | 48 distinct keys across 17 components | 17 |
Value extraction in compile() | 85+ helper calls | 17 |
| Binding construction in reducers | 60+ insert calls | 3 |
| Binding reads in reducers | 5+ bindings.get("key") | 2 |
| Total | ~200 string-keyed sites | ~20 files |
Design
Section titled “Design”Core trait: PropertyField
Section titled “Core trait: PropertyField”A trait that bridges Rust types to the property system. Implemented for primitive types and user enums.
pub trait PropertyField: Sized { /// The PropertyType this field maps to (used in schema generation). fn property_type() -> PropertyType;
/// Convert from a PropertyValue. Returns None if the value doesn't match. fn from_property_value(value: &PropertyValue) -> Option<Self>;
/// Convert to a PropertyValue for storage in bindings. fn to_property_value(&self) -> PropertyValue;}Built-in implementations:
| Rust Type | PropertyType | PropertyValue representation |
|---|---|---|
f64 | Number { min: None, max: None } | Number(f64) |
i32 | Number { min: None, max: None } | Number(f64) |
bool | Boolean | Bool(bool) |
String | Text { max_length: None } | String(String) |
Color | Color | String("#AARRGGBB") |
For enums like ShapeType, a separate #[derive(PropertyField)] macro on the enum generates the PropertyField impl with string ↔ variant mapping (see below).
Derive macro: #[derive(PropertyField)] for enums
Section titled “Derive macro: #[derive(PropertyField)] for enums”Applied to simple enums (no fields on variants) to generate the PropertyField impl. Each variant maps to a string value — by default the lowercased variant name, overridable with #[field(value = "...")].
#[derive(PropertyField)]enum ShapeType { Rect, // → "rect" Circle, // → "circle" Triangle, // → "triangle" Line, // → "line"}
#[derive(PropertyField)]enum ShapeFill { Solid, // → "solid" Transparent, // → "transparent" #[field(value = "borderOnly")] BorderOnly, // → "borderOnly" (override: not "borderonly")}
#[derive(PropertyField)]enum ArrowType { None, // → "none" Triangle, // → "triangle" Line, // → "line" Circle, // → "circle"}The macro generates for each enum:
impl PropertyField for ShapeType { fn property_type() -> PropertyType { PropertyType::Select { options: vec![opt("rect"), opt("circle"), opt("triangle"), opt("line")], multiple: false, } }
fn from_property_value(value: &PropertyValue) -> Option<Self> { match value { PropertyValue::String(s) => match s.as_str() { "rect" => Some(Self::Rect), "circle" => Some(Self::Circle), "triangle" => Some(Self::Triangle), "line" => Some(Self::Line), _ => None, }, _ => None, } }
fn to_property_value(&self) -> PropertyValue { PropertyValue::String(match self { Self::Rect => "rect", Self::Circle => "circle", Self::Triangle => "triangle", Self::Line => "line", }.into()) }}Each enum has exactly one PropertyField impl, derived on the enum itself. Multiple props structs can reference the same enum type with no conflict.
Derive macro: #[derive(ComponentProperties)]
Section titled “Derive macro: #[derive(ComponentProperties)]”Applied to a properties struct. Generates schema, extraction, binding construction, and an update struct. The macro uses <T as PropertyField>::property_type() for each field to determine the PropertyType in the schema, and PropertyField::from_property_value() / to_property_value() for conversion. The #[property] attribute provides metadata overrides (display name, constraints, defaults).
#[derive(ComponentProperties)]struct ShapeProps { #[property(name = "Shape", default = "rect")] shape_type: ShapeType,
#[property(name = "Fill", default = "solid")] fill: ShapeFill,
#[property(name = "Color")] color: Color,
#[property(name = "Stroke Color")] stroke_color: Color,
#[property(name = "Stroke Width", min = 0.0, max = 50.0, default = 2.0)] stroke_width: f64,
#[property(name = "Start Arrow", default = "none")] start_arrow: ArrowType,
#[property(name = "End Arrow", default = "none")] end_arrow: ArrowType,
#[property(name = "Rotation", min = 0.0, max = 360.0, default = 0.0)] rotation: f64,
#[property(name = "Radius", default = 0.0)] radius: f64,
#[property(name = "Flipped", default = false)] flipped: bool,}The macro generates:
1. ComponentProperties trait impl
Section titled “1. ComponentProperties trait impl”pub trait ComponentProperties: Sized { /// Property schemas for this component. fn schema() -> Vec<PropertySchema>;
/// Construct from resolved property values. Missing keys use defaults. fn from_values(values: &HashMap<String, PropertyValue>) -> Self;
/// Convert all fields to a bindings map for element creation. fn to_bindings(&self) -> HashMap<String, PropertyBinding>;}For ShapeProps, the generated schema() produces the same Vec<PropertySchema> that ShapeComponent::properties() currently returns — but derived from the struct fields and #[property] attributes.
The generated from_values() replaces all get_string/get_number/get_select_value helper calls:
// Generated — all fields use PropertyField trait for conversionimpl ComponentProperties for ShapeProps { fn from_values(values: &HashMap<String, PropertyValue>) -> Self { Self { shape_type: values.get("shape_type") .and_then(PropertyField::from_property_value) .unwrap_or_else(|| PropertyField::from_property_value( &PropertyValue::String("rect".into()) // from default = "rect" ).unwrap()), color: values.get("color") .and_then(PropertyField::from_property_value) .unwrap_or_default(), stroke_width: values.get("stroke_width") .and_then(PropertyField::from_property_value) .unwrap_or(2.0), // from default = 2.0 // ... } }}The generated to_bindings() replaces all manual bindings.insert(...) calls:
// Generatedfn to_bindings(&self) -> HashMap<String, PropertyBinding> { let mut m = HashMap::new(); m.insert("shape_type".into(), PropertyBinding::Static { value: self.shape_type.to_property_value() }); m.insert("color".into(), PropertyBinding::Static { value: self.color.to_property_value() }); // ... m}2. Update struct
Section titled “2. Update struct”The macro generates a companion ShapePropsUpdate struct with Option<T> fields:
// Generated#[derive(Default)]pub struct ShapePropsUpdate { pub shape_type: Option<ShapeType>, pub fill: Option<ShapeFill>, pub color: Option<Color>, pub stroke_color: Option<Color>, pub stroke_width: Option<f64>, pub start_arrow: Option<ArrowType>, pub end_arrow: Option<ArrowType>, pub rotation: Option<f64>, pub radius: Option<f64>, pub flipped: Option<bool>,}
impl ShapePropsUpdate { pub fn apply(self, bindings: &mut HashMap<String, PropertyBinding>) { if let Some(v) = self.shape_type { bindings.insert("shape_type".into(), PropertyBinding::Static { value: v.to_property_value() }); } if let Some(v) = self.color { bindings.insert("color".into(), PropertyBinding::Static { value: v.to_property_value() }); } // ... one arm per field }}TypedComponent trait + blanket Component impl
Section titled “TypedComponent trait + blanket Component impl”A new trait that components implement instead of Component directly:
pub trait TypedComponent: Send + Sync + 'static { type Properties: ComponentProperties; type Metadata: Hash; type Output: Clone;
/// Component identity metadata. fn id() -> &'static str; fn name() -> &'static str; fn description() -> &'static str; fn icon() -> &'static str;
/// Typed compile — receives the extracted properties struct, not a HashMap. fn compile(&self, props: &Self::Properties, metadata: &Self::Metadata) -> Result<Self::Output, CompileError>;}Blanket impl wires it to the existing Component trait:
impl<T: TypedComponent> Component for T { type Metadata = T::Metadata; type Output = T::Output;
fn properties(&self) -> Vec<PropertySchema> { T::Properties::schema() }
fn compile( &self, metadata: &Self::Metadata, values: &HashMap<String, PropertyValue>, ) -> Result<Self::Output, CompileError> { let props = T::Properties::from_values(values); TypedComponent::compile(self, &props, metadata) }}The Component trait, Modality trait, compilation pipeline, caching, Slot, and all generic machinery are completely untouched. The blanket impl bridges the typed world to the existing untyped pipeline.
Before/After: ShapeComponent
Section titled “Before/After: ShapeComponent”Before (current):
pub struct ShapeComponent;
impl Component for ShapeComponent { type Metadata = WhiteboardChildMeta; type Output = Vec<WhiteboardElement>;
fn properties(&self) -> Vec<PropertySchema> { vec![ PropertySchema { key: "shape_type".into(), name: "Shape".into(), property_type: PropertyType::Select { options: vec![opt("rect"), opt("circle"), opt("triangle"), opt("line")], multiple: false, }, default_value: Some(PropertyValue::String("rect".into())), ..Default::default() }, // ... 7 more PropertySchema blocks (~60 lines) ] }
fn compile( &self, meta: &WhiteboardChildMeta, values: &HashMap<String, PropertyValue>, ) -> Result<Vec<WhiteboardElement>, CompileError> { let shape_type = match get_select_value(values, "shape_type").as_deref() { Some("circle") => ShapeType::Circle, Some("triangle") => ShapeType::Triangle, Some("line") => ShapeType::Line, _ => ShapeType::Rect, }; let fill = match get_select_value(values, "fill").as_deref() { ... }; let color = get_color(values, "color").unwrap_or_default(); let stroke_color = get_color(values, "stroke_color").unwrap_or_default(); let stroke_width = get_number(values, "stroke_width").unwrap_or(2.0); let start_arrow = match get_select_value(values, "start_arrow").as_deref() { ... }; let end_arrow = match get_select_value(values, "end_arrow").as_deref() { ... }; let rotation = get_number(values, "rotation").unwrap_or(0.0); let radius = get_number(values, "radius").unwrap_or(0.0); let flipped = get_bool(values, "flipped").unwrap_or(false);
Ok(vec![WhiteboardElement::Shape(ShapeElement { id: meta.id.clone(), position: Offset { dx: meta.bounds.x, dy: meta.bounds.y }, size: Size { width: meta.bounds.width, height: meta.bounds.height }, shape_type, fill, color, stroke_color, stroke_width, start_arrow, end_arrow, rotation, radius, flipped, })]) }}
impl WhiteboardComponent for ShapeComponent { fn id(&self) -> &'static str { "shape" } fn name(&self) -> &'static str { "Shape" } fn description(&self) -> &'static str { "Geometric shape..." } fn icon(&self) -> &'static str { "shape_line" }}After:
#[derive(ComponentProperties)]struct ShapeProps { #[property(name = "Shape", default = "rect")] shape_type: ShapeType,
#[property(name = "Fill", default = "solid")] fill: ShapeFill,
#[property(name = "Color")] color: Color,
#[property(name = "Stroke Color")] stroke_color: Color,
#[property(name = "Stroke Width", min = 0.0, max = 50.0, default = 2.0)] stroke_width: f64,
#[property(name = "Start Arrow", default = "none")] start_arrow: ArrowType,
#[property(name = "End Arrow", default = "none")] end_arrow: ArrowType,
#[property(name = "Rotation", min = 0.0, max = 360.0, default = 0.0)] rotation: f64,
#[property(name = "Radius", default = 0.0)] radius: f64,
#[property(name = "Flipped", default = false)] flipped: bool,}
pub struct ShapeComponent;
inventory::submit!(WhiteboardComponentEntry(&ShapeComponent));
impl TypedComponent for ShapeComponent { type Properties = ShapeProps; type Metadata = WhiteboardChildMeta; type Output = Vec<WhiteboardElement>;
fn id() -> &'static str { "shape" } fn name() -> &'static str { "Shape" } fn description() -> &'static str { "Geometric shape: rectangle, circle, triangle, or line." } fn icon() -> &'static str { "shape_line" }
fn compile(&self, props: &ShapeProps, meta: &WhiteboardChildMeta) -> Result<Vec<WhiteboardElement>, CompileError> { Ok(vec![WhiteboardElement::Shape(ShapeElement { id: meta.id.clone(), position: Offset { dx: meta.bounds.x, dy: meta.bounds.y }, size: Size { width: meta.bounds.width, height: meta.bounds.height }, shape_type: props.shape_type, fill: props.fill, color: props.color, stroke_color: props.stroke_color, stroke_width: props.stroke_width, start_arrow: props.start_arrow, end_arrow: props.end_arrow, rotation: props.rotation, radius: props.radius, flipped: props.flipped, })]) }}Before/After: Reducer binding construction
Section titled “Before/After: Reducer binding construction”Before (whiteboard.rs, AddShape handler):
let mut bindings = HashMap::new();bindings.insert("shape_type".into(), bind_string(st_str));bindings.insert("fill".into(), bind_string(fill_str));bindings.insert("color".into(), bind_string(&color.to_hex()));bindings.insert("stroke_color".into(), bind_string(&color.to_hex()));bindings.insert("stroke_width".into(), bind_number(stroke_width));bindings.insert("start_arrow".into(), bind_string(arrow_str(&start_arrow)));bindings.insert("end_arrow".into(), bind_string(arrow_str(&end_arrow)));bindings.insert("radius".into(), bind_number(0.0));bindings.insert("flipped".into(), bind_bool(flipped));After:
let bindings = ShapeProps { shape_type, fill, color, stroke_color: color, stroke_width, start_arrow, end_arrow, rotation: 0.0, radius: 0.0, flipped,}.to_bindings();Before/After: Reducer property updates
Section titled “Before/After: Reducer property updates”Before (whiteboard.rs, UpdateShapeProperties handler):
if let Some(v) = shape_type { let s = serde_json::to_value(&v).unwrap().as_str().unwrap().to_string(); p.bindings.insert("shape_type".into(), bind_string(s));}if let Some(v) = fill { let s = serde_json::to_value(&v).unwrap().as_str().unwrap().to_string(); p.bindings.insert("fill".into(), bind_string(s));}if let Some(v) = color { p.bindings.insert("color".into(), bind_string(&v.to_hex())); }if let Some(v) = stroke_color { p.bindings.insert("stroke_color".into(), bind_string(&v.to_hex())); }if let Some(v) = stroke_width { p.bindings.insert("stroke_width".into(), bind_number(v)); }if let Some(v) = start_arrow { ... }if let Some(v) = end_arrow { ... }if let Some(v) = rotation { p.bindings.insert("rotation".into(), bind_number(v)); }if let Some(v) = radius { p.bindings.insert("radius".into(), bind_number(v)); }if let Some(v) = flipped { p.bindings.insert("flipped".into(), bind_bool(v)); }After:
ShapePropsUpdate { shape_type, fill, color, stroke_color, stroke_width, start_arrow, end_arrow, rotation, radius, flipped,}.apply(&mut p.bindings);#[property] attribute reference
Section titled “#[property] attribute reference”| Attribute | Type | Effect |
|---|---|---|
name = "Display Name" | String | Sets PropertySchema.name |
default = value | literal | Sets PropertySchema.default_value and from_values() fallback |
min = 0.0 | f64 | Sets PropertyType::Number { min: Some(v), .. } |
max = 100.0 | f64 | Sets PropertyType::Number { .., max: Some(v) } |
max_length = 500 | usize | Sets PropertyType::Text { max_length: Some(v) } |
description = "..." | String | Sets PropertySchema.description |
icon = "..." | String | Sets PropertySchema.icon |
system | flag | Sets PropertySchema.is_system = true |
title | flag | Sets PropertySchema.is_title = true |
toolbar = "hidden" | String | Sets PropertySchema.toolbar = ToolbarHint::Hidden (the Schema-Driven Toolbar RFC) |
required | flag | Sets PropertySchema.required = true |
Interaction with per-modality component traits
Section titled “Interaction with per-modality component traits”Currently, whiteboard and worksheet have modality-specific traits:
pub trait WhiteboardComponent: Component<Metadata = WhiteboardChildMeta, Output = Vec<WhiteboardElement>> { fn id(&self) -> &'static str; fn name(&self) -> &'static str; fn description(&self) -> &'static str; fn icon(&self) -> &'static str;}WhiteboardComponent is used as a trait object (&'static dyn WhiteboardComponent) in the inventory registry. TypedComponent has static methods (fn id() -> &'static str) which are not object-safe, so WhiteboardComponent cannot simply become a supertrait bound on TypedComponent.
Instead, WhiteboardComponent keeps its &self methods and gets a blanket impl that delegates from TypedComponent’s static methods:
// WhiteboardComponent keeps its &self methods for trait-object compatibilitypub trait WhiteboardComponent: Component<Metadata = WhiteboardChildMeta, Output = Vec<WhiteboardElement>> { fn id(&self) -> &'static str; fn name(&self) -> &'static str; fn description(&self) -> &'static str; fn icon(&self) -> &'static str;}
// Blanket impl: any TypedComponent with matching types is automatically a WhiteboardComponentimpl<T> WhiteboardComponent for Twhere T: TypedComponent<Metadata = WhiteboardChildMeta, Output = Vec<WhiteboardElement>>{ fn id(&self) -> &'static str { T::id() } fn name(&self) -> &'static str { T::name() } fn description(&self) -> &'static str { T::description() } fn icon(&self) -> &'static str { T::icon() }}The inventory registration pattern is unchanged — ShapeComponent is still a unit struct submitted via inventory::submit!. The &'static dyn WhiteboardComponent trait object works because WhiteboardComponent only has &self methods.
Interaction with existing RFCs
Section titled “Interaction with existing RFCs”the Typed Element Components RFC (Typed Element Components) — this is the direct successor. the Typed Element Components RFC established per-type components with individual properties. This RFC makes those properties compile-time typed.
the Schema-Driven Toolbar RFC (Schema-Driven Toolbar) — complementary. The #[property] attributes can include toolbar hints (toolbar = "hidden"). The macro generates the schemas that the toolbar consumes. No conflict.
What stays untyped
Section titled “What stays untyped”Modalityimpls (Whiteboard, Worksheet, LessonPlan) — these orchestrate child compilation, they don’t have property structs. They continue implementingComponentdirectly.Slot<C, O>— generic container, delegates to inner session. No properties.ComponentPlacement.bindings— remainsHashMap<String, PropertyBinding>. The typed layer converts to/from this at the boundary. Bindings must stay dynamic becausePropertyBinding::Referenceresolves at compile time from parent template values.resolve_bindings()— unchanged. Still producesHashMap<String, PropertyValue>from placement bindings + parent values. The typedfrom_values()consumes this output.- Incremental cache — unchanged. Still hashes the resolved
HashMap<String, PropertyValue>.
Macro crate location
Section titled “Macro crate location”The ComponentProperties derive macro lives in the existing crates/macros/ crate alongside CommandTool. It depends on modality_core types (PropertySchema, PropertyType, PropertyValue, PropertyBinding). The PropertyField trait and ComponentProperties trait live in modality_core (no macro dependency).
Implementation Plan
Section titled “Implementation Plan”-
Define core traits — add
PropertyField,ComponentProperties, andTypedComponenttomodality_corein a newtemplate/typed_component.rsmodule. Add built-inPropertyFieldimpls forf64,i32,bool,String. Add blanketimpl<T: TypedComponent> Component for T. Verify:cargo check --workspace -
Implement
#[derive(PropertyField)]— add tocrates/macros/. GeneratesPropertyFieldimpl for simple enums with#[field(value = "...")]attribute for string overrides. Verify: unit tests on the macro output -
Implement
#[derive(ComponentProperties)]— add tocrates/macros/. GeneratesComponentPropertiesimpl (schema(),from_values(),to_bindings()) and update struct. UsesPropertyFieldtrait bounds on all fields. Verify: unit tests on the macro output -
Add
PropertyFieldimpls for whiteboard enums — derivePropertyFieldonShapeType,ShapeFill,ArrowType,TextFontFamily,TextAlign,FileElementType,UrlType,GeogebraType. Add manualPropertyFieldimpl forColor. Verify:cargo check -p modality-whiteboard -
Migrate
ShapeComponent— first test case. Convert toShapeProps+TypedComponent. Verify: existing tests pass unchanged (cargo test -p modality-whiteboard) -
Migrate remaining whiteboard components —
PathComponent,TextComponent,TextBoxComponent,CardComponent,ListComponent,FileComponent,UrlComponent,GeogebraComponent,MaskComponent. Verify:cargo test -p modality-whiteboard -
Update
WhiteboardComponenttrait — add blanket impl fromTypedComponent, remove manualimpl WhiteboardComponentblocks. Delete whiteboardhelpers.rs. Verify:cargo test -p modality-whiteboard -
Migrate whiteboard reducer — replace
bindings.insert(...)calls withto_bindings()andPropsUpdate::apply(). Deletebind_string,bind_number,bind_bool,bind_jsonhelpers. Verify:cargo test -p modality-whiteboard -
Migrate worksheet components — derive
PropertyFieldon worksheet enums. Convert all 6 components. UpdateWorksheetComponenttrait. Delete worksheethelpers.rs. Verify:cargo test -p modality-worksheet -
Migrate assessment components — convert
MultiChoiceComponent,ShortAnswerComponent. Verify:cargo test -p modality-assessment -
Clean up — remove dead code, unused imports. Final:
cargo test --workspace
Alternatives Considered
Section titled “Alternatives Considered”Change the Component trait signature
Section titled “Change the Component trait signature”Replace &HashMap<String, PropertyValue> with a generic &Self::Properties in the Component trait itself. Rejected because the HashMap signature is threaded through 24 implementations, the Modality trait (3 methods), Session recompilation, Slot<C,O>, FRB APIs, and 50+ test calls — ~40-50 files, 200+ change points. The blanket impl achieves the same result with zero changes to the pipeline.
Derive on the component struct itself
Section titled “Derive on the component struct itself”Put fields directly on ShapeComponent instead of a separate ShapeProps. Rejected because components are registered as &'static unit structs via inventory::submit! — adding fields would require constructing them at static scope. A separate props struct is constructed at compile time from resolved values.
Specify enum options in #[property(select = [...])] instead of #[derive(PropertyField)]
Section titled “Specify enum options in #[property(select = [...])] instead of #[derive(PropertyField)]”Keep the string ↔ variant mapping on the props struct field rather than on the enum itself (e.g., #[property(select = ["rect", "circle"])]). Rejected because the macro cannot inspect the enum type’s variants from the struct derive — it only sees the type name. This would require either convention-based variant-to-string mapping (fragile) or explicit "string" => Variant syntax in the attribute (verbose). More importantly, if the same enum type appears in multiple props structs, the macro would try to generate duplicate PropertyField impls. #[derive(PropertyField)] on the enum gives exactly one impl per type, and the #[field(value = "...")] attribute handles cases where the string differs from the lowercased variant name.
Keep both compile() and compile_typed()
Section titled “Keep both compile() and compile_typed()”Have components implement both the HashMap-based compile() and a typed compile_typed(), with a default impl of compile() that calls compile_typed(). Rejected because there’s no reason to implement both — the blanket impl handles the conversion automatically. Components only implement TypedComponent::compile().
Unresolved Questions
Section titled “Unresolved Questions”Naming: ShapePropsUpdate vs ShapePropsPartial vs ShapePropsOverride
Section titled “Naming: ShapePropsUpdate vs ShapePropsPartial vs ShapePropsOverride”The auto-generated Option<T> struct for partial updates. Update is most descriptive of its purpose. Open to alternatives.
Interaction with PropertyBinding::Reference
Section titled “Interaction with PropertyBinding::Reference”The typed system handles Static bindings. Reference bindings are resolved by resolve_bindings() before reaching the component. The props struct only ever sees resolved values. However, could to_bindings() support generating Reference bindings for template authoring? This can be a follow-up — current scope is Static only.