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: The property type (container, string, int, double, bool, custom types, etc.)
required: Is this property mandatory?
default: Default value if not provided in config
enum: List of allowed string values
minimum: Minimum value for numeric types
maximum: Maximum value for numeric types
GUI Metadata:
label: Display name (e.g., "Robot Name")
doc: Documentation/help text
placeholder: Hint text for empty fields
group: Category for organizing in UI (e.g., "general", "advanced")
read_only: If true, property is not user-editable
hidden: If true, hide from GUI
Type System
PropertyTree supports the following types:
Scalar Types:
bool: Boolean (true/false)
char: Single character
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
List[type]: Dynamic sequence of items of type
List[type,N]: Fixed-size sequence of N items of type
Map[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):
- Required field presence
- Enum value membership
- Type casting correctness
- Numeric range constraints (min/max)
- Sequence/map structure validation
- Container child presence
Custom Validators:
schema.at("filepath").addValidator(
[](const PropertyTree& node, const std::string& path, std::vector<std::string>& errors) {
auto val = node.getValue().as<std::string>();
if (!val.ends_with(".yaml"))
errors.push_back(path + ": must end with .yaml");
}
);
Error Collection: The validate() method returns a vector of ALL errors, not just the first one:
auto errors = schema.validate();
if (!errors.empty()) {
std::cerr << "Validation failed with " << errors.size() << " errors:\n";
for (const auto& error : errors)
std::cerr << " - " << error << "\n";
}
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.
Derived Types & Plugin Architecture
While oneOf handles mutually exclusive branches, derived types enable inheritance hierarchies where a field can accept any type that extends a base type using the standard Tesseract plugin info structure. This is ideal for:
- Type Hierarchies: Support base types and all registered derived types
- Plugin Architectures: Accept different implementations of the same interface
- Extensibility: New derived types can be registered without modifying existing schemas
- Heterogeneous Collections: Lists and maps can contain different derived types
The Plugin Info Structure:
The standard Tesseract plugin info structure uses two fields:
class: String identifier for the concrete derived type (required)
config: YAML map containing type-specific configuration (optional but recommended)
How Derived Types Work:
- Register Schemas: Register schemas for base and derived types
- Register Hierarchy: Tell the registry which types derive from which base type
- Mark Field: Use
acceptsDerivedTypes() on a field to enable plugin info structure validation
- Validation: Each element is validated using its specified "class" type's schema
For detailed examples covering schema registration, validation, handling single entries, arrays, maps, and error handling for derived types, see Derived Types with Plugin Info Structure Example.
Error messages are hierarchical and contextual:
"constraints[0]: plugin info structure missing required 'class' field"
"constraints[1].class: type 'UnknownConstraint' does not derive from 'BaseConstraint'"
"scenario.constraints[home_position].config.joint: value cannot be empty"
Container Type Support:
Derived types work with all container types:
- Single Entry:
{ class: Type, config: {...} }
- Sequences/Arrays:
List[BaseType] - each element is a plugin info structure
- Maps:
Map[String,BaseType] - each value is a plugin info structure
.customType("constraint", "BaseConstraint")
.acceptsDerivedTypes()
.customType("constraints", "List[BaseConstraint]")
.acceptsDerivedTypes()
.customType("named_constraints", "Map[String,BaseConstraint]")
.acceptsDerivedTypes()
Backward Compatibility:
The legacy "type" field approach is still supported when acceptsDerivedTypes() is NOT set:
YAML (legacy - still works):
constraint:
type: JointPositionConstraint
joint: "joint_1"
position: 0.785
However, the plugin info structure (class + config) is the recommended approach as it:
- Clearly separates type identification from configuration
- Aligns with Tesseract's standard PluginInfo pattern
- Supports nesting and composition better
- Provides clearer YAML structure
Derived Types vs OneOf:
| Feature | Derived Types | OneOf |
| Use Case | Type inheritance hierarchies | Mutually exclusive variants |
| Type Specification | Via "class" field (explicit) | Via required field match (implicit) |
| Registration | Compile-time macros/API | Schema structure |
| Extensibility | Easy - register new derived types | Requires schema change |
| Performance | Direct type lookup | Branch matching algorithm |
| Structure | Plugin info (class + config) | Branch containers |
| Example | Constraint hierarchy | Shape (circle vs rectangle) |
For Comprehensive Coverage: See Derived Type Support in PropertyTree for a complete guide including quick start, architecture, all container types, error messages, best practices, and migration information.
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