I got tired of writing format!() for MQTT topics

Here is a bug I have shipped more than once. See if you can spot it before the compiler does. Spoiler: the compiler won't.

let topic = format!("sensors/{}/{}/temperature", sensor_id, location);

The pattern is sensors/{location}/{sensor_id}/temperature. I passed sensor_id first. It compiles. It runs. It publishes to a topic that nobody is subscribed to, and then I spend forty minutes staring at a broker that is, as far as it's concerned, working perfectly.

MQTT topics are strings. That is the whole problem. A topic is a typed routing key wearing a string costume, and every time you build one with format!() or take one apart with split('/'), you're hand-rolling the one thing the compiler is actually good at.

The before picture

Here's the honest version of receiving sensor data with a raw client. This is close to what you write with rumqttc, the async MQTT client most of the Rust ecosystem already builds on:

client.subscribe("sensors/+/+/temperature", QoS::AtMostOnce).await?;

while let Ok(notification) = eventloop.poll().await {
    match notification {
        Event::Incoming(Packet::Publish(publish)) => {
            let parts: Vec<&str> = publish.topic.split('/').collect();
            if parts.len() == 4 && parts[0] == "sensors" && parts[3] == "temperature" {
                let location = parts[1];
                let sensor_id = parts[2];

                match serde_json::from_slice::<SensorData>(&publish.payload) {
                    Ok(data) => {
                        println!("Sensor {sensor_id} in {location}: {}°C", data.temperature);
                    }
                    Err(e) => eprintln!("Deserialization error: {e}"),
                }
            }
        }
        _ => {}
    }
}

It works. I'm not going to pretend it doesn't. But count the places where it silently lies to you:

None of these are caught by the compiler, because to the compiler this is all just &str. You find out at runtime, usually in production, usually on a Friday.

The after picture

Here is the same thing with mqtt-typed-client. You declare the topic shape once as a struct:

#[mqtt_topic("sensors/{location}/{sensor_id}/temperature")]
struct TemperatureTopic {
    location: String,
    sensor_id: String,
    payload: SensorData,
}

And then receiving looks like this:

let mut subscriber = client.temperature_topic().subscribe().await?;

while let Some(result) = subscriber.receive().await {
    match result {
        Ok(msg) => {
            println!("Sensor {} in {}: {}°C",
                msg.sensor_id, msg.location, msg.payload.temperature);
        }
        Err(e) => eprintln!("Deserialization error: {e}"),
    }
}

msg.location and msg.sensor_id are fields. If I swap them now, I'm swapping two named fields of the same type and at least I have a name to look at. And the moment one of them is a different type, the swap stops compiling outright. The "sensors" and "temperature" literals exist in exactly one place, the attribute, and the subscription filter sensors/+/+/temperature is generated from that same string, so they can't drift apart.

Publishing is the part I missed most:

client.temperature_topic().publish("kitchen", "sensor_001", &data).await?;

The arguments are positional and typed against the pattern. There is no format!, no serde_json::to_vec, no QoS argument to forget. If you give it the wrong number of arguments, it doesn't compile.

Where the "magic" actually is

I don't love calling it magic, because it's a fairly boring proc-macro and I'd rather you trust it than be impressed by it. #[mqtt_topic("...")] parses the pattern string at compile time and matches each {param} against a field on your struct. Out of that it generates three things. A typed publisher, with arguments in the same order as the pattern. A subscriber that pulls those params back out of whatever topic actually came in. And an extension method on the client (temperature_topic()), so you get autocomplete instead of guessing at strings. Parameters go out through Display and come back in through FromStr, which is why {device_id} can be a u32 and not just a String. "42" parses into 42u32 on the way in, and a bad parse becomes a typed error in the stream instead of a panic. Underneath, all of it is still rumqttc doing the actual protocol work; the routing from "one byte stream off the socket" to "this subscriber's typed messages" is a topic-matching tree, not a chain of if topic.starts_with(...).

That's the entire pitch. You write the topic shape once, and the places that used to fail silently now either fail at compile time or don't fail.

The honest part

This is a layer on top of rumqttc, not a replacement for it. That matters, and here's when you should walk away from my crate:

For the case I actually have, which is "an application with a handful of known topic patterns and a serde type behind each one," the typed layer pays for itself the first time it refuses to compile something I would otherwise have debugged at 11pm.

It's MIT/Apache-2.0, built on rumqttc, serializers for bincode/JSON/MessagePack/CBOR and a few more behind feature flags. If you've ever shipped the swapped-format!()-arguments bug, this is me trying to make the compiler your friend in the one place it normally isn't.

cargo add mqtt-typed-client. Tell me where it falls over.


mqtt-typed-client on GitHub · docs.rs · crates.io