The formatter specialization mechanism in std::format looks intimidating on first read but turns out to be quite clean once you see the pattern. Spencer Collyer wrote a solid walkthrough back in November covering how to plug user-defined types into the library, and it is worth revisiting for anyone who has been putting off this part of C++20.
The core of it: to make a type work with std::format, you specialize std::formatter<T> and implement two methods, parse() and format(). The parse() method reads the format spec string (the part after the colon in {:.2f}), and format() does the actual work of writing to the output iterator.
Here is what a minimal implementation looks like:
struct Point { int x, y; };
template <>
struct std::formatter<Point> {
constexpr auto parse(std::format_parse_context& ctx) {
return ctx.begin();
}
auto format(const Point& p, std::format_context& ctx) const {
return std::format_to(ctx.out(), "({}, {})", p.x, p.y);
}
};
With that specialization in place:
Point p{3, 7};
std::string s = std::format("Position: {}", p);
// "Position: (3, 7)"
The parse() here is trivially simple. Returning ctx.begin() signals that no format spec was consumed. If you want to support options like fill, width, or alignment for your type, parse() is where you read and store them, then apply them inside format().
There is a constraint worth knowing: parse() must be constexpr-capable. The compiler evaluates format strings at compile time when it can, which is how std::format catches type mismatches before the program runs. Your parse() implementation has to play along with that, so anything involving runtime state or dynamic allocation is off the table there.
The composition story is also good. If your type contains fields that are themselves formattable, you can delegate to their own formatters inside format(). And std::format_to writes directly to ctx.out(), so there is no intermediate string allocation.
Where It Gets More Involved
The Collyer article goes further than the minimal case. Supporting the standard fill-and-align and width options requires actually parsing the format spec in parse() and applying the results in format(). That is more code, but the article walks through it with a concrete example rather than just describing it abstractly.
Anyone who has used the {fmt} library will find this familiar territory since std::format is largely based on it. The specialization pattern is essentially the same. If you have not used either, the payoff over operator<< overloads is meaningful: type safety at compile time, consistent syntax across your codebase, and no accidental state manipulation on stream objects.
For Discord bot work or anything that does a lot of structured logging, having your domain types format cleanly through std::format is more useful than it might seem at first. Debug output that reads User{id=42, name="alice"} beats digging through raw integer dumps.