How fmtlib Handles Arguments and Performs Type Erasure in C++

{fmt} passes all formatting arguments through a lightweight type-erasure pipeline so the formatting engine can process heterogeneous C++ types uniformly using a compact, contiguous array of tagged values.

The fmtlib/fmt library achieves high performance and type safety by decoupling argument storage from the formatting engine. When you call fmt::format(), your values are transformed into a portable, type-erased representation that the formatter traverses at runtime. This article examines the complete argument handling pipeline in the {fmt} source code, from ingestion to the final formatting call.

The Three Core Components of Argument Handling

{fmt} implements type erasure through three tightly integrated classes, each defined in a specific header file.

basic_format_arg — The Type-Erased Value Container

The fundamental unit of argument storage is basic_format_arg<Context>, defined in include/fmt/core.h. This small object combines a value union with a type tag (detail::type) that tells the formatter how to interpret the stored bits.

The union inside basic_format_arg can hold:

  • Integral types (int_type, uint_type, long_long_type, etc.)
  • Floating-point values (double_type, long_double_type)
  • Pointers and strings (pointer_type, cstring_type, string_view_type)
  • Custom types via a custom_value handle
  • Named argument references

When constructed, basic_format_arg receives the actual value (or pointer) plus a compile-time-deduced detail::type enum value. This tag completely replaces compile-time type information with a runtime identifier that the formatting engine checks when extracting values.

basic_format_args — The Lightweight View

The basic_format_args<Context> class in include/fmt/format.h provides a non-owning view over a contiguous array of basic_format_arg objects. It stores:

  • A pointer to the first argument (data_)
  • The argument count (size_)
  • A boolean flag indicating named argument presence (named_)

This design follows the C++ Core Guidelines principle of separating ownership from access. The view is what low-level functions like vformat() and format_to() actually consume—no templates, no heavy machinery, just pointer arithmetic over type-tagged values.

dynamic_format_arg_store — The Dynamic Builder

When argument count or types are unknown at compile time, dynamic_format_arg_store<Context> in include/fmt/args.h builds the basic_format_arg array dynamically. This class manages two separate storage arenas:

  • data_ — a std::vector<basic_format_arg<Context>> holding the type-erased argument descriptors
  • dynamic_args_ — a detail::dynamic_arg_list linked list owning any heap-allocated objects that cannot fit directly in the basic_format_arg union

The store's critical responsibility is deciding whether each incoming argument needs copying or can be referenced.

Copy vs. Reference: The need_copy Decision

Inside dynamic_format_arg_store::push_back<T>() (lines 78-90 of args.h), the trait need_copy<T> makes the storage determination:

Condition Behavior
need_copy<T>::value == false Store reference directly in basic_format_arg; no heap allocation
need_copy<T>::value == true Allocate typed_node<T> in dynamic_args_ linked list, store reference in vector

A copy is required for:

  • Non-reference-wrapper types that aren't simple string views
  • Custom types not already basic_format_arg-compatible
  • Values that would dangle if referenced (temporary objects)

The dynamic_arg_list uses a node hierarchy where each typed_node<T> contains the actual storage plus type-erased deallocation logic. This lets {fmt} handle arbitrarily complex types while keeping the hot-path basic_format_arg vector compact and cache-friendly.

The Complete Argument Pipeline

Here's how the pieces flow together in a typical formatting call:

  1. Argument ingestion — fmt::format("{:d} {}", 42, "hello") instantiates format_string<T...> and forwards each argument through detail::make_arg<T>() via the fmt::arg helper.

  2. Type erasure — Each basic_format_arg constructor receives the value and its detail::type tag (e.g., type::int_type, type::cstring_type). The original C++ type information is discarded; only the tag remains.

  3. View creation — dynamic_format_arg_store exposes operator basic_format_args<Context>() const, which constructs a view pointing at data_.data().

  4. Formatting execution — vformat() or equivalent iterates the view, reads each argument's type tag, extracts the appropriate union member, and applies format specifications.

  5. Named argument handling — When fmt::arg("name", value) is used, the store inserts a placeholder at index 0 and maintains a parallel named_info_ vector mapping names to indices. The named flag in basic_format_args signals the formatter to resolve {name} references.

Working with Type-Erased Arguments: Code Examples

Basic Compile-Time Formatting

#include <fmt/format.h>

// Type erasure happens automatically behind this simple API
std::string s = fmt::format("The answer is {} and {}", 42, 3.14);
// Result: "The answer is 42 and 3.14"

Even this straightforward call internally populates a dynamic_format_arg_store, converts to basic_format_args, and dispatches through vformat.

Dynamic Store for Runtime Argument Counts

#include <fmt/args.h>
#include <fmt/format.h>
#include <functional>  // std::cref

fmt::dynamic_format_arg_store<fmt::format_context> store;

store.push_back(42);                                   // copied
store.push_back(std::string("hello"));                 // copied (via node)
store.push_back(std::cref(s));                         // referenced, no copy

// Convert to view and format
std::string result = fmt::vformat("{} – {}", store);
// Result: "42 – hello"

Named Arguments

#include <fmt/args.h>
#include <fmt/format.h>

auto args = fmt::make_format_args(
    fmt::arg("x", 10),
    fmt::arg("y", std::string_view{"world"}));

std::string txt = fmt::vformat("{x} says {y}", args);
// Result: "10 says world"

All three examples compile to the same underlying mechanism: a type-erased argument store converted to a lightweight view that the formatting engine processes uniformly.

Key Design Trade-offs

The {fmt} argument handling architecture makes deliberate engineering choices:

  • Contiguous storage for the hot path — The basic_format_arg vector minimizes cache misses during format string parsing.
  • Separate dynamic storage for cold data — Only values requiring allocation hit the linked list; simple types stay inline.
  • Type tags instead of RTTI — The detail::type enum provides fast switching without typeid overhead.
  • Views instead of owning containers — basic_format_args enables zero-cost passing to internal functions.

These decisions align with {fmt}'s goal of matching or exceeding printf performance while maintaining full type safety.

Summary

  • basic_format_arg in include/fmt/core.h holds type-erased values via a tagged union, removing original C++ type information at runtime.
  • basic_format_args in include/fmt/format.h provides a non-owning view over argument arrays for uniform formatter consumption.
  • dynamic_format_arg_store in include/fmt/args.h builds argument collections dynamically, using need_copy<T> to decide between referencing and heap-allocating values.
  • The copy-or-reference decision keeps simple arguments inline while safely managing complex or temporary objects through dynamic_arg_list.
  • The named argument mechanism uses index 0 placeholders and a separate name-to-index map, signaled via the named flag in the view.

Frequently Asked Questions

How does {fmt} avoid virtual function overhead in type erasure?

Rather than using virtual functions or std::any, {fmt} stores arguments in a union with an explicit detail::type enum tag. The formatter switches on this integer tag to extract the correct union member—branch prediction and inlining make this faster than virtual dispatch or RTTI lookups.

Can I use dynamic_format_arg_store with custom types?

Yes. If your custom type has a formatter<T> specialization, dynamic_format_arg_store will store it via typed_node<T> in the dynamic_arg_list when need_copy<T> requires copying. The type erasure preserves enough information for the formatter to invoke your custom format() method via the stored function pointer.

Why does {fmt} use a linked list for dynamic storage instead of std::vector?

The dynamic_arg_list linked list in args.h allows heterogeneous types in a single container without std::any's type-erasure overhead. Each typed_node<T> knows its own deallocation size, enabling type-safe cleanup without virtual destructors. This isolates allocation costs to only those arguments that require it.

What's the performance cost of type erasure in {fmt}?

For primitive types and string views, the cost is negligible—the basic_format_arg vector stores values inline with no indirection. Heap allocation and indirect access only occur for types where need_copy<T> is true. Benchmarks show {fmt} matching or exceeding printf performance despite the type safety guarantees.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →