Skip to content

feat(macros): derive ParameterVariant for enums and newtypes - #693

Open
azerupi wants to merge 1 commit into
azerupi/params/parameter-setsfrom
azerupi/params/derive-parameter-variant
Open

feat(macros): derive ParameterVariant for enums and newtypes#693
azerupi wants to merge 1 commit into
azerupi/params/parameter-setsfrom
azerupi/params/derive-parameter-variant

Conversation

@azerupi

@azerupi azerupi commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

The code in this PR was assisted by Claude Code.

Problem

Using a custom enum or newtype as a parameter is boilerplate heavy. It requires three trait implementations, and a constraints string kept in step with the type by hand:

#[derive(Clone, Debug, PartialEq)]
enum ControlMode {
    Velocity,
    Position,
    Effort,
}

impl From<ControlMode> for ParameterValue {
    fn from(value: ControlMode) -> Self {
        ParameterValue::String(
            match value {
                ControlMode::Velocity => "velocity",
                ControlMode::Position => "position",
                ControlMode::Effort => "torque",
            }
            .into(),
        )
    }
}

impl TryFrom<ParameterValue> for ControlMode {
    type Error = ParameterValueError;

    fn try_from(value: ParameterValue) -> Result<Self, Self::Error> {
        match value {
            ParameterValue::String(s) => match s.as_ref() {
                "velocity" => Ok(ControlMode::Velocity),
                "position" => Ok(ControlMode::Position),
                "torque" => Ok(ControlMode::Effort),
                other => Err(ParameterValueError::Invalid(format!(
                    "unknown ControlMode '{other}', expected one of: velocity, position, torque"
                ))),
            },
            _ => Err(ParameterValueError::TypeMismatch),
        }
    }
}

impl ParameterVariant for ControlMode {
    type Range = ();

    fn kind() -> ParameterKind {
        ParameterKind::String
    }

    fn type_constraints() -> Option<Arc<str>> {
        Some("one of: velocity, position, torque".into())
    }
}

declare_parameter_field!(ControlMode);

Solution

#[derive(ParameterVariant)] generates all of that. The representation follows from the shape of the type, so the enum above becomes:

/// Which quantity a controller closes the loop on.
#[derive(ParameterVariant, Clone, Copy, Debug, PartialEq)]
#[parameter(rename_all = "snake_case")]
enum ControlMode {
    Velocity,
    Position,
    #[parameter(rename = "torque")]
    Effort,
}

let mode = node.declare_parameter("mode").default(ControlMode::Velocity).mandatory()?;

match mode.get() {
    ControlMode::Velocity => ...,
    ControlMode::Position => ...,
    ControlMode::Effort   => ...,   // exhaustive, no impossible arm
}

The valid values reach the descriptor, so introspection reports them without any declaration restating them:

$ ros2 param describe /controller mode
  Constraints: one of: velocity, position, torque

$ ros2 param set /controller mode reverse
Setting parameter failed: unknown ControlMode 'reverse', expected one of:
velocity, position, torque

This works for 3 type patterns:

Written Stored as
an enum whose variants carry no data a string, one spelling per variant
#[parameter(transparent)] on a newtype or struct with one field whatever the wrapped value is stored as
#[parameter(from_str)] a string, via FromStr and Display

transparent uses the inner type's range and its validation, so units cost nothing:

#[derive(ParameterVariant, Clone, Copy, PartialEq, PartialOrd, Default)]
#[parameter(transparent)]
struct Meters(pub f64);

// range in metres, because the range type comes from the f64 inside
node.declare_parameter("depth").default(Meters(1.0)).range(0.0..=10.0).mandatory()?;

and a Port(u16) rejects 70000 exactly as a u16 does, because it is a u16 underneath.

from_str is for a type that already parses itself. The FromStr error becomes the reason a value was rejected, so it is worth writing well:

#[derive(ParameterVariant, Clone, Debug, PartialEq)]
#[parameter(from_str)]
struct Hostname(String);

A derived type is a parameter value everywhere: through the builder, as a field of a #[derive(ParameterSet)] struct, and through declare_parameter_with, since ParameterConversion::of_variant assembles a conversion from what the derive emits.

The derive also emits declare_parameter_field!, so a derived type is usable as a set field with no further work.

A parameter could only be declared with a type rclrs knew about, so a
closed set of choices had to be handled as a string and compared against
string literals at every use, and a value with a unit had to be an
unadorned f64.

representation. Which one follows from the shape of the type:

  #[derive(ParameterVariant, Clone, Copy, PartialEq)]
  #[parameter(rename_all = "snake_case")]
  enum ControlMode { Velocity, Position, #[parameter(rename = "torque")] Effort }

An enum whose variants carry no data becomes a string of variant names,
which is how a choice is written in a parameter file. The valid values go
into the descriptor's constraints, so `ros2 param describe` reports them,
and a value that is not one of them is rejected -- including over the
parameter services -- with a message naming them.

value's representation, along with its range type and its validation, so a
Meters(f64) parameter takes a range in metres and a Port(u16) one rejects
70000 exactly as a u16 would. #[parameter(from_str)] stores a type as a
string via FromStr and Display, reporting the FromStr error as the reason
a value was rejected.

Structs whose fields are individually meaningful are groups of parameters
rather than values, and the macro says so rather than trying to represent
them.

Assisted-by: Claude:claude-opus-5 [Claude Code]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant