A hierarchical, schema-based configuration system with built-in validation and metadata support.
PropertyTree is a powerful configuration management system that bridges YAML-based configuration files with C++ applications. It provides:
- Hierarchical Structure: Organize configuration as a tree of properties
- Schema Support: Define the expected structure, types, and constraints
- Metadata Attachment: Attach documentation, UI hints, and custom attributes to properties
- Config Merge: Apply user configurations to schemas with automatic defaults
- Comprehensive Validation: Collect ALL validation errors at once, not just the first
- Custom Validation: Attach domain-specific validator functions to properties
- Type Safety: Enforce type constraints (int, string, double, enums, sequences, maps, etc.)
- Range Constraints: Enforce minimum/maximum values for numeric properties
- UI Generation: Use metadata to auto-generate configuration UI
Architecture
Core Components
- PropertyTree: A single node representing a property with value, attributes, and children
- PropertyTreeBuilder: Fluent API for constructing schemas with clean syntax
- SchemaRegistry: Global registry for named, reusable schemas
- Validators: Built-in validators (required, enum, type, range) + custom validators
Typical Workflow
auto schema = PropertyTreeBuilder()
.container("robot")
.string("name").required().done()
.integer("dof").minimum(1).maximum(20).done()
.done()
.build();
YAML::Node user_config = YAML::LoadFile("config.yaml");
schema.mergeConfig(user_config);
auto errors = schema.validate();
if (!errors.empty()) {
for (const auto& error : errors)
std::cerr << error << "\n";
return false;
}
std::string robot_name = schema.at("robot").at("name").as<std::string>();
int dof = schema.at("robot").at("dof").as<int>();
Attributes
PropertyTree supports metadata attributes that control behavior and appearance:
Type Attributes:**
Type System
PropertyTree supports the following types:
Scalar Types:**
bool: Boolean (true/false)
char: Single character
std::string: Text strings
int: 32-bit integers
unsigned int: Unsigned 32-bit integers
long int: 64-bit signed integers
long unsigned int: 64-bit unsigned integers
float: 32-bit floating point
double: 64-bit floating point
Composite Types:**
container: A container of named child properties
type[]: Dynamic sequence of items of type
type[N]: Fixed-size sequence of N items of type
{key,type}: Map from key type to value type
Custom types: Any registered user-defined type
Eigen Types (built-in):**
Eigen::Isometry3d: 4x4 homogeneous transformation
Eigen::VectorXd: Dynamic vector
Eigen::Vector2d: 2D vector
Eigen::Vector3d: 3D vector
Fluent Builder API
PropertyTreeBuilder provides a clean, readable syntax for defining schemas:
auto schema = PropertyTreeBuilder()
.container("config")
.doc("Main configuration")
.string("name")
.required()
.doc("Application name")
.label("App Name")
.placeholder("my_app")
.done()
.integer("version")
.minimum(1)
.maximum(999)
.defaultVal(1)
.done()
.done()
.build();
Type methods: container, string, character, boolean, integer, unsignedInt, longInt, longUnsignedInt, floatNum, doubleNum, customType
Attribute setters: doc, required, defaultVal, enumValues, minimum, maximum, label, placeholder, group, readOnly, hidden, validator, attribute
Validation
PropertyTree supports multiple validation mechanisms:
Built-in Validators (automatic from attributes):**
OneOf - Polymorphic Configuration
OneOf enables polymorphic configuration structures where a config must match exactly one of multiple branches. This is useful for:
- Union-like Types: Choose between different configuration variants
- Plugin Selection: Allow different plugin configurations mutually exclusively
- Shape Definitions: Specify geometry as circle (radius) OR rectangle (width/height)
Communication Types: Define transport as USB OR Ethernet with type-specific fields
How OneOf Works:** When merging config into a oneOf schema, PropertyTree:
- Examines the user config to find required fields
- Identifies which branch schema has all its required fields satisfied
- Fails if zero branches match (no branch applicable to config)
- Fails if multiple branches match (ambiguous - could be either)
Selects the matching branch and flattens the schema to that branch's structure
Example - Shape Definition:**
auto shape_schema = PropertyTreeBuilder()
.attribute(TYPE, ONEOF)
.container("circle")
.doubleNum("radius").required().minimum(0.0).done()
.done()
.container("rectangle")
.doubleNum("width").required().minimum(0.0).done()
.doubleNum("height").required().minimum(0.0).done()
.done()
.build();
YAML::Node config1;
config1["radius"] = 5.0;
shape_schema.mergeConfig(config1);
auto error1 = shape_schema.validate();
YAML::Node config2;
config2["width"] = 10.0;
config2["height"] = 20.0;
shape_schema.mergeConfig(config2);
auto error2 = shape_schema.validate();
Example - Plugin Selection:**
auto plugin_schema = PropertyTreeBuilder()
.attribute(TYPE, ONEOF)
.container("usb_plugin")
.string("port").required().done()
.integer("baud_rate").required().done()
.done()
.container("ethernet_plugin")
.string("ip_address").required().done()
.integer("port").required().done()
.done()
.build();
YAML::Node usb_config;
usb_config["port"] = "/dev/ttyUSB0";
usb_config["baud_rate"] = 115200;
plugin_schema.mergeConfig(usb_config);
Branch Selection Rules:**
- A branch is "applicable" if all its required fields are present in the config
- If zero branches are applicable, throws
std::runtime_error("oneOf: no branch matches")
- If multiple branches are applicable, throws
std::runtime_error("oneOf: multiple branches match") (This can happen if branches have no required fields or overlapping required fields)
See Tesseract PropertyTree OneOf Example for a detailed working example.
Polymorphic Type Hierarchies
While oneOf handles mutually exclusive branches, derived types handle inheritance hierarchies where a field can accept any type that extends a base type. This is useful for:
- Register Inheritance: Tell the registry what types derive from a base
- Mark Field: Use
acceptsDerivedTypes() to allow derived types in that field
- Type Field: Configuration data includes a "type" field naming the concrete type
Validation: System checks if the type is registered as derived from the base (or equals base)
Example - Constraint Hierarchy:**
auto base_schema = PropertyTreeBuilder()
.doubleNum("weight").defaultVal(1.0).done()
.build();
auto position_schema = PropertyTreeBuilder()
.doubleNum("weight").defaultVal(1.0).done()
.vectorXd("target_position").required().done()
.doubleNum("target_tolerance").defaultVal(0.01).done()
.build();
auto orientation_schema = PropertyTreeBuilder()
.doubleNum("weight").defaultVal(1.0).done()
.isometry("target_orientation").required().done()
.doubleNum("target_tolerance").defaultVal(0.1).done()
.build();
registerSchema("Constraint", base_schema);
registerSchema("PositionConstraint", position_schema);
registerSchema("OrientationConstraint", orientation_schema);
auto config_schema = PropertyTreeBuilder()
.sequence("constraints", createList("Constraint"))
.customType("constraints",
[]() { return registerSchema("Constraint", base_schema); })
.acceptsDerivedTypes()
.validator(validateCustomType)
.done()
.build();
#define TESSERACT_SCHEMA_REGISTER_DERIVED_TYPE(BASE_TYPE, DERIVED_TYPE)
Macro to register that a derived type can be used where a base type is expected.
Definition schema_registration.h:85
Configuration Using Derived Types:**
YAML:
constraints:
- type: PositionConstraint # Validates against PositionConstraint schema
weight: 2.0
target_position: [1, 2, 3]
target_tolerance: 0.005
- type: OrientationConstraint # Validates against OrientationConstraint schema
weight: 1.5
target_orientation: [1, 0, 0, 0] # Quaternion as [x, y, z, w]
target_tolerance: 0.1
- type: Constraint # Using base type is also valid
weight: 0.5
Validation with Derived Types:**
When validating a sequence element with acceptsDerivedTypes():
- Extract the "type" field from the element
- Check if the type is registered (exact match or derived from base)
- If registered, validate the element against its type's schema
- If not registered, report an error listing valid types
Error messages include the element's array index for clarity:
"constraints[1].type: value 'UnknownConstraint' is not derived from 'Constraint'.
Valid types: Constraint, PositionConstraint, OrientationConstraint"
Derived Types vs OneOf:**
| Feature | Derived Types | OneOf |
| Use Case | Type inheritance hierarchies | Mutually exclusive variants |
| Type Discovery | Via "type" field (explicit) | Via required field match (implicit) |
| Registration | Compile-time macros | Schema structure |
| Extensibility | Easy - register new derived types | Requires schema change |
| Performance | Direct type lookup | Branch matching algorithm |
| Example | Constraint, PositionConstraint | Shape (circle vs rectangle) |
See Tesseract PropertyTree Polymorphic Types Example for a detailed working example.
Schema Registry
Register and retrieve schemas globally:
auto& registry = SchemaRegistry::instance();
auto schema = PropertyTreeBuilder()...build();
registry->registerSchema("my::Config", schema);
auto retrieved = registry->get("my::Config");
if (registry->contains("my::Config")) {
...
}
Built-in schemas are registered for Eigen types and tesseract common types via the YAML::convert specializations in yaml_extensions.cpp.
Examples
See the following examples for detailed working code:
Common Patterns
Pattern 1: Configuration File Loading**
auto schema = PropertyTreeBuilder()...build();
YAML::Node user_config = YAML::LoadFile("app.yaml");
schema.mergeConfig(user_config);
auto errors = schema.validate();
if (!errors.empty()) return false;
auto name = schema.at("name").as<std::string>();
Pattern 2: Schema Registration for Reuse**
PropertyTreeBuilder builder;
builder.container("settings")...;
SchemaRegistry::instance()->registerSchema("AppSettings", builder.build());
auto schema = SchemaRegistry::instance()->get("AppSettings");
schema.mergeConfig(user_config);
auto errors = schema.validate();
Pattern 3: UI Generation**
auto schema = PropertyTreeBuilder()
.string("name").label("User Name").placeholder("Enter name...").done()
.integer("age").minimum(0).maximum(150).label("Age").done()
.build();
for (const auto& key : schema.keys()) {
auto& prop = schema.at(key);
auto label = prop.getAttribute("label");
auto doc = prop.getAttribute("doc");
auto type = prop.getAttribute("type");
}
Best Practices
- Define Schemas Early: Build schemas at module initialization when possible
- Register Reusable Schemas: Use SchemaRegistry for schemas used in multiple places
- Use Fluent API: PropertyTreeBuilder is cleaner than manual tree construction
- Validate Before Use: Always call validate() and check the error vector
- Attach Metadata: Use doc, label, group for better UI and documentation
- Custom Validators: Add domain-specific validators for complex constraints
- Meaningful Defaults: Provide sensible defaults for optional properties
- Error Messages: Make error messages actionable and include the error path
- Test Schemas: Write unit tests for schema validation with valid and invalid configs
- Document Attributes: Use doc attributes to explain what each property does