ROS 2 provides both compile-time typed and runtime-typed publisher and subscriber APIs. This article explains how those APIs differ, what type_name controls, how serialized-message adapters work, and how arbitrary payloads such as Protobuf should be transported safely.

The central distinction is:

A generic ROS 2 endpoint is runtime-typed and pre-serialized. It is not an untyped arbitrary-byte endpoint.

Typed ROS 2 publishers and subscribers

A normal publisher knows its message type at C++ compile time:

auto publisher = node->create_publisher<geometry_msgs::msg::Twist>(
    "/cmd_vel", rclcpp::QoS(10));

geometry_msgs::msg::Twist message;
message.linear.x = 1.0;
publisher->publish(message);

Publisher<T> obtains the generated type support for T. ROS serializes the C++ object before passing the resulting bytes to RMW/DDS.

A typed subscriber reverses this operation:

auto subscription = node->create_subscription<geometry_msgs::msg::Twist>(
    "/cmd_vel",
    rclcpp::QoS(10),
    [](geometry_msgs::msg::Twist::ConstSharedPtr message) {
        useVelocity(message->linear.x);
    });

ROS receives the serialized sample, deserializes it as Twist, and invokes the callback with a typed C++ object.

Generic ROS 2 publishers and subscribers

A generic endpoint moves type selection from compile time to runtime:

auto publisher = node->create_generic_publisher(
    "/cmd_vel",
    "geometry_msgs/msg/Twist",
    rclcpp::QoS(10));

ROS uses the runtime type name to load the installed ROSIDL type-support library and create a typed RMW/DDS writer. The generic publisher itself does not accept an arbitrary void* message or infer its C++ type. Its normal publishing API accepts an already serialized ROS message:

void rclcpp::GenericPublisher::publish(
    const rclcpp::SerializedMessage& message);

For example:

geometry_msgs::msg::Twist message;
message.linear.x = 1.0;

rclcpp::Serialization<geometry_msgs::msg::Twist> serializer;
rclcpp::SerializedMessage serialized;
serializer.serialize_message(&message, &serialized);

publisher->publish(serialized);

A generic subscription receives the serialized form:

auto subscription = node->create_generic_subscription(
    "/cmd_vel",
    "geometry_msgs/msg/Twist",
    rclcpp::QoS(10),
    [](std::shared_ptr<rclcpp::SerializedMessage> serialized) {
        geometry_msgs::msg::Twist message;
        rclcpp::Serialization<geometry_msgs::msg::Twist> serializer;
        serializer.deserialize_message(serialized.get(), &message);
        useVelocity(message.linear.x);
    });

Type erasure and why generic APIs are useful

The generic API erases the compile-time C++ message type while retaining the ROS/DDS type at runtime:

Typed endpoint:
  Publisher<T>
  -> publish(const T&)

Generic endpoint:
  GenericPublisher
  -> runtime type_name
  -> publish(const SerializedMessage&)

The erased information is the concrete C++ object type. The information that remains includes the ROS type name, dynamically loaded type-support handle, topic identity, QoS, and serialized wire representation.

A final semantic consumer usually must decode the message using the correct type. The advantage is that intermediate infrastructure does not need to decode it at all.

Type-independent relays

A relay can forward any installed ROS message type without including its generated C++ header:

struct GenericRelay
{
    rclcpp::GenericPublisher::SharedPtr publisher;
    rclcpp::GenericSubscription::SharedPtr subscription;
};

GenericRelay createRelay(
    rclcpp::Node& node,
    const std::string& input_topic,
    const std::string& output_topic,
    const std::string& type_name,
    const rclcpp::QoS& qos)
{
    GenericRelay relay;

    relay.publisher = node.create_generic_publisher(
        output_topic, type_name, qos);

    auto publisher = relay.publisher;
    relay.subscription = node.create_generic_subscription(
        input_topic,
        type_name,
        qos,
        [publisher](
            std::shared_ptr<rclcpp::SerializedMessage> message) {
            publisher->publish(*message);
        });

    return relay;
}

The same implementation can relay String, Twist, Image, Odometry, or a newly installed custom type. It does not need a template instantiation, header dependency, or dispatch branch for every message type.

Recording and replay

Rosbag is the canonical use case. Recording needs only:

topic name
runtime ROS type name
QoS metadata
serialized bytes

Replay reads the recorded type name, creates a generic publisher, and republishes the stored serialized samples. Neither operation needs to construct the corresponding C++ message object.

Bridges, routers, and observability tools

The same type-erased boundary is useful for:

  • Network and middleware bridges.
  • Topic routers and proxies.
  • Logging and archival systems.
  • Latency and bandwidth measurement.
  • Gateways that preserve ROS samples.
  • Tools that copy messages without interpreting their fields.

These components remain decoupled from application message packages and do not need recompilation every time a new message type is introduced.

When field access is required

A component that needs semantic fields has two choices:

  1. Use the correct compile-time type and rclcpp::Serialization<T>.
  2. Load introspection or dynamic type support and decode fields at runtime.

This produces a useful architectural separation:

Semantic producers and consumers
  -> typed C++ messages

Transport, relay, record, and replay infrastructure
  -> runtime type name plus SerializedMessage

Runtime inspection tools
  -> SerializedMessage plus introspection metadata

Type erasure therefore does not eliminate types. It postpones typed decoding until a component actually needs message semantics.

Practical API selection

Generic pub/sub is mainly an infrastructure API. It is most valuable when a framework or tool must support message types selected at runtime and should not depend directly on every generated ROS message package.

Ordinary application code usually knows its message type and needs semantic fields. In that situation, the typed API is clearer:

auto subscription =
    node->create_subscription<geometry_msgs::msg::Twist>(
        "/cmd_vel",
        qos,
        [](geometry_msgs::msg::Twist::ConstSharedPtr message) {
            useVelocity(message->linear.x);
        });

Using a generic subscription and immediately decoding into the same known C++ type adds an unnecessary layer:

auto subscription = node->create_generic_subscription(
    "/cmd_vel",
    "geometry_msgs/msg/Twist",
    qos,
    [](std::shared_ptr<rclcpp::SerializedMessage> serialized) {
        geometry_msgs::msg::Twist message;
        rclcpp::Serialization<geometry_msgs::msg::Twist> serializer;
        serializer.deserialize_message(serialized.get(), &message);
        useVelocity(message.linear.x);
    });

The generic version still includes and links the Twist message package, still requires Twist type support, manually performs work already provided by the typed subscription, and introduces a possible mismatch between the runtime type name and the C++ decoder type. It normally provides no advantage for business logic that already knows the type.

A practical decision rule is:

Known type + semantic field access
  -> typed Publisher<T> and Subscription<T>

Runtime-selected type + transport or storage only
  -> GenericPublisher and GenericSubscription

Runtime-selected type + semantic field access
  -> generic endpoints plus introspection or dynamic decoding

Exceptions exist, such as applications that load message-processing plugins at runtime. The general rule remains that once code already knows T and needs T, receiving a SerializedMessage only to decode it immediately is unnecessary.

Detailed example: runtime field rules with Humble APIs

Consider a monitoring framework configured with a rule rather than a compiled message callback:

topic: /battery_state
field: percentage
operator: less_than
threshold: 0.15

The framework is compiled without including sensor_msgs/msg/battery_state.hpp. At startup it asks the ROS graph for the topic’s runtime type name. The serialized CDR payload is not self-describing; the type name and its installed type support supply the missing schema.

The example below uses APIs available in ROS 2 Humble. It handles a scalar float32 field such as BatteryState.percentage and shows the complete lifecycle: dynamic-library loading, type-support lookup, allocation, initialization, deserialization, field lookup, value access, finalization, and release.

#include <rclcpp/rclcpp.hpp>
#include <rclcpp/serialization.hpp>
#include <rclcpp/typesupport_helpers.hpp>

#include <rosidl_runtime_cpp/message_initialization.hpp>
#include <rosidl_typesupport_introspection_cpp/field_types.hpp>
#include <rosidl_typesupport_introspection_cpp/message_introspection.hpp>

#include <rcpputils/shared_library.hpp>

#include <cstdint>
#include <cstring>
#include <memory>
#include <new>
#include <stdexcept>
#include <string>
#include <utility>

using MessageMember =
    rosidl_typesupport_introspection_cpp::MessageMember;
using MessageMembers =
    rosidl_typesupport_introspection_cpp::MessageMembers;

class InitializedMessage
{
public:
    explicit InitializedMessage(const MessageMembers* members)
        : members_(members),
          storage_(::operator new(members_->size_of_))
    {
        try {
            members_->init_function(
                storage_,
                rosidl_runtime_cpp::MessageInitialization::ALL);
        } catch (...) {
            ::operator delete(storage_);
            storage_ = nullptr;
            throw;
        }
    }

    ~InitializedMessage()
    {
        if (storage_ != nullptr) {
            members_->fini_function(storage_);
            ::operator delete(storage_);
        }
    }

    InitializedMessage(const InitializedMessage&) = delete;
    InitializedMessage& operator=(const InitializedMessage&) = delete;
    InitializedMessage(InitializedMessage&&) = delete;
    InitializedMessage& operator=(InitializedMessage&&) = delete;

    void* data() noexcept
    {
        return storage_;
    }

private:
    const MessageMembers* members_;
    void* storage_;
};

class RuntimeFloatRule
{
public:
    RuntimeFloatRule(
        rclcpp::Node& node,
        std::string topic,
        std::string field,
        float threshold)
        : node_(node),
          topic_(std::move(topic)),
          field_(std::move(field)),
          threshold_(threshold)
    {
        const auto topics = node_.get_topic_names_and_types();
        const auto topic_it = topics.find(topic_);
        if (topic_it == topics.end() || topic_it->second.size() != 1U) {
            throw std::runtime_error(
                "topic must exist with exactly one ROS type");
        }
        type_name_ = topic_it->second.front();

        // Used by SerializationBase to call the active RMW serializer.
        rmw_library_ = rclcpp::get_typesupport_library(
            type_name_, "rosidl_typesupport_cpp");
        rmw_type_support_ = rclcpp::get_typesupport_handle(
            type_name_, "rosidl_typesupport_cpp", *rmw_library_);

        // Used to discover message size, lifecycle functions, fields,
        // field types, nested members, and byte offsets.
        introspection_library_ = rclcpp::get_typesupport_library(
            type_name_, "rosidl_typesupport_introspection_cpp");
        const auto* introspection_handle =
            rclcpp::get_typesupport_handle(
                type_name_,
                "rosidl_typesupport_introspection_cpp",
                *introspection_library_);

        if (introspection_handle == nullptr ||
            introspection_handle->data == nullptr) {
            throw std::runtime_error(
                "introspection type support has no MessageMembers");
        }

        members_ = static_cast<const MessageMembers*>(
            introspection_handle->data);
        serialization_ =
            std::make_unique<rclcpp::SerializationBase>(
                rmw_type_support_);

        subscription_ = node_.create_generic_subscription(
            topic_,
            type_name_,
            rclcpp::QoS(10),
            [this](
                std::shared_ptr<rclcpp::SerializedMessage> serialized) {
                evaluate(*serialized);
            });
    }

private:
    [[nodiscard]] const MessageMember* findField() const
    {
        for (uint32_t index = 0; index < members_->member_count_; ++index) {
            const auto& member = members_->members_[index];
            if (field_ == member.name_) {
                return &member;
            }
        }
        return nullptr;
    }

    void evaluate(const rclcpp::SerializedMessage& serialized)
    {
        try {
            // Allocate and construct the generated C++ message object using
            // only runtime introspection metadata.
            InitializedMessage message(members_);

            // Deserialize CDR through the active RMW implementation into
            // that initialized message storage.
            serialization_->deserialize_message(
                &serialized,
                message.data());

            const MessageMember* member = findField();
            if (member == nullptr) {
                throw std::runtime_error("configured field was not found");
            }
            if (member->is_array_ ||
                member->type_id_ !=
                    rosidl_typesupport_introspection_cpp::ROS_TYPE_FLOAT) {
                throw std::runtime_error(
                    "configured field is not a scalar float32");
            }

            // offset_ locates the field inside the generated message object.
            // memcpy avoids alignment and aliasing assumptions in generic code.
            const auto* address =
                static_cast<const uint8_t*>(message.data()) +
                member->offset_;
            float value = 0.0F;
            std::memcpy(&value, address, sizeof(value));

            if (value < threshold_) {
                RCLCPP_WARN(
                    node_.get_logger(),
                    "%s.%s is %.3f, below %.3f",
                    topic_.c_str(),
                    field_.c_str(),
                    static_cast<double>(value),
                    static_cast<double>(threshold_));
            }
        } catch (const std::exception& error) {
            RCLCPP_ERROR(
                node_.get_logger(),
                "dynamic rule evaluation failed: %s",
                error.what());
        }
    }

    rclcpp::Node& node_;
    std::string topic_;
    std::string field_;
    std::string type_name_;
    float threshold_;

    // Libraries must remain loaded while their handles and callbacks are used.
    std::shared_ptr<rcpputils::SharedLibrary> rmw_library_;
    std::shared_ptr<rcpputils::SharedLibrary> introspection_library_;
    const rosidl_message_type_support_t* rmw_type_support_{nullptr};
    const MessageMembers* members_{nullptr};
    std::unique_ptr<rclcpp::SerializationBase> serialization_;
    rclcpp::GenericSubscription::SharedPtr subscription_;
};

The important internal sequence is visible directly in the code:

ROS graph discovery
  -> "sensor_msgs/msg/BatteryState"

get_typesupport_library(..., "rosidl_typesupport_cpp")
  -> RMW serialization implementation

get_typesupport_library(
    ..., "rosidl_typesupport_introspection_cpp")
  -> MessageMembers schema metadata

MessageMembers::size_of_
  -> allocate enough storage for the generated C++ message object

MessageMembers::init_function
  -> construct strings, vectors, nested messages, and other fields

SerializationBase::deserialize_message
  -> deserialize CDR into the initialized message storage

MessageMember::name_ + type_id_ + offset_
  -> find "percentage", verify float32, and locate its value

MessageMembers::fini_function
  -> destroy all dynamically managed fields

operator delete
  -> release the raw message storage

The introspection library still contains generated and compiled support for BatteryState. The monitoring framework does not include the BatteryState C++ header or instantiate a BatteryState subscription; it selects and loads that support through the discovered runtime type name.

Nested paths such as twist.twist.linear.x use the same metadata recursively. When a MessageMember has ROS_TYPE_MESSAGE, its members_ handle points to the nested MessageMembers. A resolver advances the storage pointer by offset_, obtains the nested schema from members_->data, and repeats for the next path component. Arrays and sequences use size_function and get_const_function rather than direct scalar access.

If the runtime type name, RMW type support, or introspection type support is unavailable, the framework cannot interpret the fields and must leave the payload opaque. Type erasure removes the compile-time dependency; it does not remove the need for runtime type identity, generated type support, and schema.

Why a generic endpoint still requires a real ROS type

Generic means that the application-facing C++ class is not templated. The underlying ROS 2 and DDS endpoint remains strongly typed.

For this call:

node->create_generic_publisher(
    "/cmd_vel", "geometry_msgs/msg/Twist", qos);

ROS approximately performs:

auto library = rclcpp::get_typesupport_library(
    "geometry_msgs/msg/Twist", "rosidl_typesupport_cpp");
auto handle = rclcpp::get_typesupport_handle(
    "geometry_msgs/msg/Twist", "rosidl_typesupport_cpp", *library);

The resulting handle is used to:

  • Create and register the RMW/DDS endpoint.
  • Advertise the type through the ROS graph.
  • Match publishers and subscribers with the same type identity.
  • Provide middleware-specific type metadata.
  • Support typed deserialization on the receiving side.

The type name is therefore not redundant. It controls endpoint identity, but the serialized publishing path does not invoke its serializer for every message because the caller has already supplied serialized bytes.

The application must keep these two facts consistent:

declared endpoint type = actual type represented by the serialized bytes

Generic and typed endpoint interoperability

Generic and typed endpoints communicate normally when their topic type and QoS are compatible:

Publisher Subscriber Result
Typed Typed Typed object at both application boundaries
Generic Typed Sender supplies serialized bytes; receiver gets T
Typed Generic Sender publishes T; receiver gets serialized bytes
Generic Generic Both application boundaries use serialized bytes

The generic/typed distinction is local to the C++ API. Their ROS/DDS wire representation is identical.

Endpoint creation verifies that the type name resolves to installed type support. Discovery also requires compatible topic names, type identities, and QoS. Publishing a SerializedMessage, however, does not reliably validate that every byte is a correct serialization of the declared type.

Serialized bytes must match the declared ROS type

ROS 2 transmission remains strongly typed

The generic C++ API does not make the underlying ROS 2 channel untyped. When a generic publisher is created, type_name selects a real ROS message type and its installed type support. That type becomes part of the endpoint identity used by ROS graph discovery, RMW, and DDS.

Every sample on that channel is therefore expected to be the serialized wire representation of the declared type:

GenericPublisher configured as geometry_msgs/msg/Twist
    requires
SerializedMessage containing a serialized Twist

This requirement allows the generic publisher to communicate with every compatible ROS 2 endpoint. A typed Subscription<Twist> can receive the sample, deserialize it as Twist, and expose a valid C++ object. Rosbag, bridges, routers, and other middleware tools can also process the sample using the advertised type information.

Generic publishing can bypass payload validation

GenericPublisher::publish() accepts an rclcpp::SerializedMessage, so the application can mechanically place arbitrary bytes in that buffer and ask the middleware to publish them. The publishing path may not inspect those bytes or prove that they represent the declared ROS type.

That capability does not change the channel contract. If arbitrary bytes are published under an unrelated type_name, the metadata says one type while the payload contains another representation:

declared channel type: std_msgs/msg/ByteMultiArray
actual payload bytes: raw Protobuf

A generic subscriber that only forwards opaque buffers might appear to work, because it never decodes the sample. A receiver that follows the ROS type contract will instead attempt to deserialize those bytes as ByteMultiArray. Deserialization may fail, produce invalid data, or otherwise behave incorrectly. The same mismatch can break typed subscribers, rosbag, bridges, content filters, and interoperability with another RMW implementation.

The distinction is therefore:

Mechanically possible:
    put arbitrary bytes in SerializedMessage and publish them

Valid ROS 2 contract:
    SerializedMessage contains the serialized form of type_name

A byte-oriented adapter built on generic pub/sub must expose the second contract. Its input is not arbitrary application payload; it is a complete serialized ROS sample whose type matches the endpoint’s runtime type_name.

Use a real ROS envelope whose data field carries the opaque payload. A common choice is std_msgs/msg/ByteMultiArray:

Protobuf/custom bytes
  -> ByteMultiArray.data
  -> serialize complete ByteMultiArray as ROS CDR
  -> GenericPublisher configured as ByteMultiArray

The receiver performs the inverse:

SerializedMessage
  -> deserialize ByteMultiArray
  -> expose ByteMultiArray.data as the application payload

For richer routing and schema information, define a dedicated envelope:

# opaque_msgs/msg/OpaquePayload.msg
string encoding
string schema
uint8[] data

The recommended adapter behavior for an arbitrary-byte channel is:

send(payload, size)
  -> construct OpaquePayload or ByteMultiArray
  -> serialize the outer ROS message
  -> GenericPublisher::publish(serialized)

receive(serialized)
  -> deserialize the outer ROS message
  -> callback(payload.data(), payload.size())

This preserves an arbitrary-byte application abstraction while remaining valid for typed ROS subscribers, rosbag record/replay, bridges, and different RMW implementations.

Generic publisher example

auto publisher = node->create_generic_publisher(
    "/opaque_payload",
    "std_msgs/msg/ByteMultiArray",
    rclcpp::QoS(10));

std_msgs::msg::ByteMultiArray envelope;
envelope.data = protobuf_payload;

rclcpp::Serialization<std_msgs::msg::ByteMultiArray> serializer;
rclcpp::SerializedMessage serialized;
serializer.serialize_message(&envelope, &serialized);
publisher->publish(serialized);

Generic receiver example

auto subscription = node->create_generic_subscription(
    "/opaque_payload",
    "std_msgs/msg/ByteMultiArray",
    rclcpp::QoS(10),
    [](std::shared_ptr<rclcpp::SerializedMessage> serialized) {
        std_msgs::msg::ByteMultiArray envelope;
        rclcpp::Serialization<std_msgs::msg::ByteMultiArray> serializer;
        serializer.deserialize_message(serialized.get(), &envelope);
        parseProtobuf(envelope.data);
    });

Typed receiver example

auto subscription =
    node->create_subscription<std_msgs::msg::ByteMultiArray>(
        "/opaque_payload",
        rclcpp::QoS(10),
        [](std_msgs::msg::ByteMultiArray::ConstSharedPtr message) {
            parseProtobuf(message->data);
        });