Comprehensive guide to using derived types with the plugin info structure.
Overview
PropertyTree provides comprehensive support for derived types in configuration validation. This feature enables extensible, inheritance-based configuration hierarchies where fields can accept any properly registered derived type of a base type.
Key Features
- Heterogeneous Collections: Arrays and maps can contain different derived types
- Dynamic Registration: Loading a plugin library can register new schemas without changing the host schema
- Plugin Pattern: Uses the standard Tesseract "class" + "config" plugin info structure
- Type Safety: Validation ensures types are properly registered and configs are valid
- Clear Error Messages: Reports invalid types with available options
The implementation uses the standard Tesseract plugin info structure (class + config) to cleanly separate type identification from configuration:
field:
class: DerivedTypeIdentifier # Type identifier (required)
config: # Type-specific config (optional)
param1: value1
param2: value2
This aligns with the PluginInfo struct used throughout Tesseract for extensible components.
Quick Start
Here's the three-step process to use derived types:
Step 1: Register Types
PropertyTree baseConstraintSchema()
{
return PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
.float64("weight").defaultVal(1.0).done()
.build();
}
PropertyTree jointPositionConstraintSchema()
{
return PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
.float64("weight").defaultVal(1.0).done()
.string("joint").required().done()
.float64("position").required().done()
.build();
}
#define TESSERACT_SCHEMA_REGISTER(KEY, SCHEMA_SOURCE)
Macro to register either a file‐based schema or a function‐built schema.
Definition schema_registration.h:64
#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
Step 2: Define Schema with acceptsDerivedTypes()
auto schema = PropertyTreeBuilder()
.container("constraints")
.customType("list", "List[BaseConstraint]")
.acceptsDerivedTypes()
.done()
.done()
.build();
Step 3: Use Plugin Info Structure in YAML
constraints:
- class: JointPositionConstraint
config:
joint: "joint_1"
position: 0.785
- class: BaseConstraint
config:
weight: 1.0
- That's it! The schema will validate that each element has a valid class name and config values.
Strict Registration Contract
Derived-type validation is intentionally strict. A type is usable only when both its concrete schema and its relationship to the declared base type are registered. Registering only the relationship is an error; validation does not fall back to an empty schema. This guarantees that a plugin's config is checked rather than silently accepted.
Plugin loaders must load the libraries named by search_libraries before validating the complete configuration so that static schema registrations have executed. Libraries that contribute schemas must remain loaded while registry entries can be used because schemas may contain validator callbacks implemented in those libraries.
TESSERACT_SCHEMA_REGISTER(KEY, SCHEMA_SOURCE) stringizes KEY and accepts either a schema-producing function or a schema file path as SCHEMA_SOURCE. TESSERACT_SCHEMA_REGISTER_DERIVED_TYPE(BASE_TYPE, DERIVED_TYPE) stringizes both type names and records their compatibility. Place both macros at file scope in a .cpp file. Their static initializers run when the executable or plugin library is loaded. Registration order is not significant provided the schema and relationship both exist before validation starts.
PluginInfoContainer schemas require a plugins map. An empty container is represented explicitly as:
Plugin Discovery and Library Lifetime
Plugin schemas are commonly registered by static initializers inside shared libraries. The complete plugin configuration therefore cannot be validated until the libraries named by that same configuration have been loaded. Tesseract resolves this bootstrap dependency with two validation stages.
PluginDiscoveryInfo contains the optional search_paths and search_libraries string lists. The complete ProfilesPluginInfo, KinematicsPluginInfo, ContactManagersPluginInfo, and TaskComposerPluginInfo value types derive from it, while their schemas compose the same discovery fields into the existing flat YAML representation.
Two-Stage Validation
- Validate discovery metadata: Merge the complete plugin YAML into the
PluginDiscoveryInfo schema with extra properties allowed. This validates only search_paths and search_libraries; plugin-specific fields are intentionally ignored during this bootstrap pass.
- Load and retain libraries: Configure a candidate
boost_plugin_loader::PluginLoader, then call SchemaRegistry::loadAndRetainPluginLibraries(). Loading executes static schema registrations. The registry keeps independent lifetime tokens, deduplicated by resolved library path.
- Validate the complete configuration: Merge a fresh clone into the complete schema with extra properties disallowed, then validate strictly.
- Decode and commit: Decode from an unmodified YAML snapshot and commit the candidate loader and plugin data only after complete validation succeeds.
YAML::Node snapshot = YAML::Clone(plugin_config);
auto discovery_tree = YAML::convert<PluginDiscoveryInfo>::schema();
discovery_tree.mergeConfig(YAML::Clone(snapshot), true);
auto errors = discovery_tree.validate(true);
if (!errors.empty())
throw std::runtime_error("Plugin discovery validation failed");
const auto discovery = snapshot.as<PluginDiscoveryInfo>();
boost_plugin_loader::PluginLoader candidate_loader = current_loader;
candidate_loader.search_paths.insert(candidate_loader.search_paths.end(),
discovery.search_paths.begin(), discovery.search_paths.end());
candidate_loader.search_libraries.insert(candidate_loader.search_libraries.end(),
discovery.search_libraries.begin(), discovery.search_libraries.end());
SchemaRegistry::instance()->loadAndRetainPluginLibraries(candidate_loader);
auto config_tree = YAML::convert<CompletePluginInfo>::schema();
config_tree.mergeConfig(YAML::Clone(snapshot), false);
errors = config_tree.validate(false);
if (!errors.empty())
throw std::runtime_error("Complete plugin configuration validation failed");
auto complete_info = snapshot.as<CompletePluginInfo>();
current_loader = std::move(candidate_loader);
The candidate loader gives the factory local transaction semantics: failed complete validation does not modify its search paths, libraries, or decoded plugin maps. Loading a library can still add process-wide schema registrations; those global registrations and retained library handles are intentionally not rolled back.
Why the Registry Retains Libraries
A PropertyTree schema can contain std::function validators whose executable code resides in a plugin library. Unloading that library while the schema remains registered would leave invalid callbacks. The registry therefore destroys registered schemas before releasing its retained library lifetime tokens. Callers do not need to retain the candidate loader merely to keep registered schema callbacks valid.
Common Patterns
Single Entry
.customType("constraint", "BaseConstraint")
.acceptsDerivedTypes()
constraint:
class: JointPositionConstraint
config:
joint: "joint_1"
position: 0.785
Array/List
.customType("constraints", "List[BaseConstraint]")
.acceptsDerivedTypes()
constraints:
- class: JointPositionConstraint
config: { joint: "joint_1", position: 0.0 }
- class: CollisionConstraint
config: { constraint_type: "USE_LIMITS" }
Map
.customType("named_constraints", "Map[String,BaseConstraint]")
.acceptsDerivedTypes()
named_constraints:
home_position:
class: JointPositionConstraint
config: { joint: "wrist_3", position: 1.57 }
safety_limit:
class: CollisionConstraint
config: { constraint_type: "USE_LIMITS" }
Architecture
Key Components
-
validatePluginInfo()
-
Validates individual plugin info structures (
class + config).
-
Checks that
class identifies a compatible derived type.
-
Requires the concrete class schema to be registered.
-
Validates
config against the derived type's schema.
-
Provides hierarchical error messages.
-
validateCustomType()
-
Detects the
acceptsDerivedTypes() attribute.
-
Uses plugin info structure validation when enabled.
-
Iterates single entries, sequences, and maps while adding element paths.
-
Routes configurations containing the legacy
type field through legacy validation.
-
SchemaRegistry
-
Owns registered schemas and base-to-derived relationships.
-
Loads plugin libraries before taking its registry mutex so static registration cannot deadlock.
-
Retains independent library handles while registered schemas may reference plugin code.
Usage Guide
Declaring Fields for Derived Types
To enable plugin info structure validation on a field, use acceptsDerivedTypes():
PropertyTreeBuilder()
.customType("constraint", "BaseConstraint")
.acceptsDerivedTypes()
.done()
acceptsDerivedTypes() applies to the current custom-type field and enables validation of the standard class + config plugin info structure. Call it after customType() and before done().
Plugin Container Helpers
The self-closing pluginContainer() and pluginContainerMap() helpers build the standard Tesseract plugin configuration shapes without manually repeating their schemas:
auto schema = PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
.pluginContainer("executors", "my::ExecutorFactory", "Executor")
.pluginContainerMap("kinematics", "my::KinematicsFactory", "Kinematics")
.build();
Both helpers arrange derived-type validation for plugin entries. They are self-closing, so no matching done() call is required. The plugins map inside a plugin container is required; represent an empty container as plugins: {}.
pluginContainer() and pluginContainerMap() record the boost_plugin_loader section used to discover implementations. This allows generic configuration tools to discover compatible aliases and retrieve their schemas without depending on the concrete plugin factory. The section is mandatory because every plugin must belong to an export section:
auto schema = PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
.attribute(property_attribute::CONFIG_KEY, "kinematic_plugins")
.pluginContainerMap("fwd_kin_plugins", "tesseract::kinematics::FwdKinFactory", "FwdKin")
.pluginContainerMap("inv_kin_plugins", "tesseract::kinematics::InvKinFactory", "InvKin")
.build();
For a direct PluginInfoContainer, such as a contact-manager configuration, use the corresponding overload:
auto schema = PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
.attribute(property_attribute::CONFIG_KEY, "contact_manager_plugins")
.pluginContainer("discrete_plugins", "tesseract::collision::DiscreteContactManagerFactory", "DiscColl")
.pluginContainer("continuous_plugins", "tesseract::collision::ContinuousContactManagerFactory", "ContColl")
.build();
These helpers attach plugin_base_type and plugin_section attributes to the container node. config_key identifies an optional outer YAML document key. Discovery input fields can be identified with plugin_discovery_role, whose standard values are search_paths and search_libraries.
Single Entry Example
For a single derived type instance:
- Schema
auto schema = PropertyTreeBuilder()
.container("planning_config")
.customType("constraint", "BaseConstraint")
.acceptsDerivedTypes()
.doc("A single constraint instance")
.done()
.done()
.build();
- YAML
planning_config:
constraint:
class: JointPositionConstraint
config:
joint: "shoulder_1"
position: 0.785
tolerance: 0.01
Array/Vector Example
For multiple instances of derived types:
- Schema
auto schema = PropertyTreeBuilder()
.container("planning_problem")
.customType("constraints", "List[BaseConstraint]")
.acceptsDerivedTypes()
.doc("Array of constraints (each can be any derived type)")
.done()
.done()
.build();
- YAML
planning_problem:
constraints:
- class: JointPositionConstraint
config:
joint: "joint_1"
position: 0.0
tolerance: 0.005
- class: CartesianVelocityConstraint
config:
frame: "tool0"
max_velocity: 1.5
- class: CollisionConstraint
config:
constraint_type: "USE_LIMITS"
safety_margin: 0.05
Map Example
For named instances of derived types:
- Schema
auto schema = PropertyTreeBuilder()
.container("scenario")
.customType("constraints", "Map[String,BaseConstraint]")
.acceptsDerivedTypes()
.doc("Map of named constraints")
.done()
.done()
.build();
- YAML
scenario:
constraints:
home_position:
class: JointPositionConstraint
config:
joint: "wrist_3"
position: 1.57
safety_limits:
class: CollisionConstraint
config:
constraint_type: "USE_LIMITS"
safety_margin: 0.05
smooth_motion:
class: CartesianVelocityConstraint
config:
frame: "workspace_center"
max_velocity: 2.0
Registration
Before using derived types, register the type relationships:
PropertyTree collisionConstraintSchema()
{
return PropertyTreeBuilder()
.attribute(property_attribute::TYPE, property_type::CONTAINER)
.float64("weight").defaultVal(1.0).done()
.string("constraint_type").required().enumValues({"USE_LIMITS", "PENALTY"}).done()
.float64("safety_margin").defaultVal(0.05).done()
.build();
}
Registration macros belong at file scope. Do not place them in headers included by multiple translation units. Applications may instead call SchemaRegistry::registerSchema() and SchemaRegistry::registerDerivedType() directly when explicit runtime registration is preferable.
Validation Process
When validating a property with acceptsDerivedTypes() enabled:
- Extract class field: Required "class" field must be present
- Verify inheritance: Check if the class name is registered as a valid derived type
- Validate config: Validate the "config" field against that type's schema
- Collect errors: All validation errors are collected with proper path context
Error Messages
Error messages are hierarchical and contextual:
"planning_config.constraint: plugin info structure missing required 'class' field"
"planning_config.constraint.class: type 'UnknownConstraint' does not derive from 'BaseConstraint'"
"planning_problem.constraints[1].config.max_velocity: value 1.2 is less than minimum 1.5"
"scenario.constraints[safety_limits].config.constraint_type: not in enum values"
Supported Container Types
Derived types are directly supported in the following forms:
| Container Type | Type Notation | YAML Pattern |
| Single Entry | BaseType | { class: ..., config: ... } |
| Dynamic Array | List[BaseType] | [ { class: ..., config: ... }, ... ] |
| Fixed Array | List[BaseType,N] | Fixed-size array of plugin infos |
| Map | Map[String,BaseType] | { key1: { class: ..., config: ... }, ... } |
Nested type expressions such as Map[String,List[BaseType]] are not parsed. Model nested collections with an explicit intermediate registered schema instead. pluginContainerMap() uses this pattern internally.
Backward Compatibility
The legacy "type" field approach remains supported:
- Legacy Approach (still works)
constraint:
type: JointPositionConstraint
joint: "joint_1"
position: 0.785
This works when acceptsDerivedTypes() is not set. However, the plugin info structure is now the recommended approach because it:
- Clearly separates type identification from configuration
- Aligns with Tesseract's standard PluginInfo pattern
- Supports better composition and nesting
- Provides clearer YAML organization
Derived Types vs. OneOf
Both features enable polymorphic-like configuration but serve different purposes:
| Feature | Derived Types | OneOf |
| Use Case | Type inheritance hierarchies (heterogeneous) | Mutually exclusive variants |
| Type Specification | Explicit "class" field | Implicit - determined by required fields present |
| Flexibility | Each element can be different derived type | All choices defined at schema time |
| Extensibility | Very easy - register new types at runtime | Requires schema modification |
| Structure | Plugin info (class + config) | Multiple sibling containers |
| Array/Map Elements | Can mix different types | Must be single type variant |
| Example Use Case | Constraints (different at each position) | Shape types (Circle vs. Rectangle) |
When to use Derived Types
- Type hierarchies with inheritance relationships
- Plugin architectures where new types are registered dynamically
- Collections that mix different derived types in same array/map
- Standard type selection via "class" field (plugin info pattern)
When to use OneOf
- Mutually exclusive configuration options
- Variant types without inheritance notion
- Configuration structure determines type
Complete Working Example
See the comprehensive example at:
- Doxygen Source File:
common/examples/property_tree_derived_types_example.cpp
The example demonstrates:
- Single constraint entry
- Array of mixed constraint types
- Named constraints in a map
- Error handling and validation
- Registration patterns
Implementation Details
The implementation is located in:
Best Practices
- Use Plugin Info Structure: Always use class + config pattern with
acceptsDerivedTypes()
- Register Early: Register derived types at module initialization
- Clear Class Names: Use fully qualified type names (e.g., "tesseract::planning::JointPositionConstraint")
- Validate Config: Always include required fields in derived type schemas
- Error Handling: Check validation results before accessing configuration
- Documentation: Add description metadata to explain which types are valid
- Consistency: Use nested config for all type-specific parameters
- Testing: Test with all registered derived types
- Versioning: Consider schema version in derived type registration for compatibility
Quick Reference Guide
Plugin Info Structure
Every derived type instance has two fields:
field:
class: TypeIdentifier # Type name (required)
config: # Type-specific parameters (optional)
param1: value1
param2: value2
Common Error Messages
| Error | Meaning | Solution |
| plugin info structure missing required class field | No class identifier | Add class: TypeName |
| type X does not derive from BaseType | Invalid type name | Register the type or use a valid derived type |
| value cannot be empty (in config) | Config validation failed | Check schema requirements for the type |
| expected a plugin info structure | Wrong YAML structure | Use { class: ..., config: ... } format |
Validation Flow
- Check "class" field exists (required)
- Verify class is registered as derived from base type
- Validate "config" against that type's schema
- Collect all errors with path context
Schema Attributes
Key attributes for derived type fields:
.customType("field_name", "BaseType")
.acceptsDerivedTypes()
.doc("Description")
.required()
.done()
The .acceptsDerivedTypes() attribute is what enables plugin info structure validation.
Container Types
| Container | Type Notation | YAML Example |
| Single | BaseType | { class: Type, config: {...} } |
| Array | List[BaseType] | [ { class: Type, config: {...} }, ... ] |
| Fixed Array | List[BaseType,N] | Fixed-size array of plugin infos |
| Map | Map[String,BaseType] | { key1: { class: Type, config: {...} }, ... } |
Implementation Patterns
Pattern 1: Extensible Plugin System
.customType("solver", "BaseSolver")
.acceptsDerivedTypes()
.doc("Any registered solver implementation can be used here")
Pattern 2: Multiple Configurations
.customType("constraints", "List[BaseConstraint]")
.acceptsDerivedTypes()
.doc("List of constraints - can mix different types")
Pattern 3: Named Component Map
.customType("plugins", "Map[String,BasePlugin]")
.acceptsDerivedTypes()
.doc("Named plugin instances")
Legacy "Type" Field
When acceptsDerivedTypes() is not set, the old "type" field approach still works:
constraint:
type: JointPositionConstraint
joint: "joint_1"
position: 0.785
However, use plugin info structure (class + config) for new code.
Migration Guide
To migrate from the legacy "type" field approach:
- Before (legacy, still works)
constraints:
- type: JointPositionConstraint
joint: "joint_1"
position: 0.0
.customType("constraints", "List[BaseConstraint]")
- After (recommended)
constraints:
- class: JointPositionConstraint
config:
joint: "joint_1"
position: 0.0
.customType("constraints", "List[BaseConstraint]")
.acceptsDerivedTypes()
- Changes Required
- Add
.acceptsDerivedTypes() to the schema definition
- Restructure YAML: wrap config in "config" field
- Change "type" field to "class" field in YAML files
- No C++ code changes to validation logic - automatic detection of new structure