Return Type Resolver
A proxy object with conversion operators lets one function produce different return types, selected implicitly by the assignment context.
Return Type Resolversince C++98A lightweight proxy object, returned from a function, that carries implicit conversion operators so the caller's assignment target type determines which concrete value is produced.
Overview
C++ does not permit overloading on return type. Two functions with the same name and parameter types but different return types are ill-formed. The Return Type Resolver sidesteps this constraint entirely by deferring type selection to the call site: the function returns a small resolver object that exposes operator T() for each type it can produce. When the caller assigns the result to a typed variable, the compiler invokes the matching conversion and the correct value materialises.
int port = cfg_get("server.port"); // resolver::operator int()
double ratio = cfg_get("cache.ratio"); // resolver::operator double()The pattern is valid since C++98, which introduced user-defined conversion operators. C++11 added explicit conversion operators, closing a long-standing loophole where operator bool() fired silently in boolean contexts. C++17 did not alter the core mechanism, but [[nodiscard]], std::from_chars, std::variant, and std::optional all make resolvers more practical to implement correctly.
Three scenarios make this idiom earn its weight:
- Context-typed retrieval β a single access function (config store, environment, database row) must produce typed results without repeating the type at every call site.
- Lazy construction β the resolver captures input cheaply and defers expensive work until the target type is known.
- Generic lexical conversion β a
lexical_cast-style facility that must produce any arithmetic or string type from a text source.
The idiom is distinct from template functions that take the target type as an explicit parameter (get<int>("key")). Both approaches work; the resolver is preferable when the target type is always deducible from the assignment and you want call sites that read like typed assignment, not explicit casts.
Syntax
The minimal shape is a resolver class returned by value:
class TypeResolver {
/* source data captured here */
public:
operator int() const;
operator double() const;
operator std::string() const;
explicit operator bool() const; // C++11: require explicit cast to bool
};
[[nodiscard]] TypeResolver resolve(/* ... */); // C++17: [[nodiscard]]A template conversion operator (C++11) generalises to any type with an appropriate constructor or conversion from the source:
class GenericResolver {
std::string raw_;
public:
explicit GenericResolver(std::string s) : raw_(std::move(s)) {} // C++11 move
template <typename T>
operator T() const;
template <typename T>
operator T*() const = delete; // C++11: prevent pointer conversions
};Examples
Typed configuration reader
Environment variables and INI-style configs are fundamentally strings. A resolver makes each read site idiomatic:
#include <charconv> // C++17
#include <stdexcept>
#include <string>
class ConfigValue {
std::string raw_;
public:
explicit ConfigValue(std::string raw) : raw_(std::move(raw)) {} // C++11
operator int() const {
int result{};
auto [ptr, ec] = std::from_chars( // C++17 structured bindings
raw_.data(), raw_.data() + raw_.size(), result);
if (ec != std::errc{})
throw std::invalid_argument("not an int: " + raw_);
return result;
}
operator double() const { return std::stod(raw_); }
operator std::string() const { return raw_; }
explicit operator bool() const { // C++11: must cast explicitly
return raw_ == "1" || raw_ == "true" || raw_ == "yes";
}
};
[[nodiscard]] ConfigValue cfg_get(const std::string& key); // C++17
void apply_config() {
int port = cfg_get("server.port"); // operator int()
double ratio = cfg_get("cache.ratio"); // operator double()
std::string host = cfg_get("server.host"); // operator std::string()
bool debug = static_cast<bool>(cfg_get("debug")); // explicit required
}std::from_chars (C++17) is preferred over std::stoi for integer parsing: it is locale-independent, allocation-free, and reports errors without exceptions.
Database column resolver
#include <optional> // C++17
#include <variant> // C++17
using DbScalar = std::variant<std::nullptr_t, int64_t, double, std::string>; // C++17
class ColumnValue {
DbScalar val_;
public:
explicit ColumnValue(DbScalar v) : val_(std::move(v)) {} // C++11
operator int64_t() const {
return std::get<int64_t>(val_); // throws std::bad_variant_access on mismatch
}
operator double() const {
return std::get<double>(val_);
}
operator std::string() const {
return std::get<std::string>(val_);
}
operator std::optional<std::string>() const { // C++17: nullable conversion
if (std::holds_alternative<std::nullptr_t>(val_))
return std::nullopt;
return std::get<std::string>(val_);
}
explicit operator bool() const { // C++11
return !std::holds_alternative<std::nullptr_t>(val_); // C++17
}
};
// Usage β row["col"] returns ColumnValue
int64_t user_id = row["user_id"];
std::string username = row["username"];
std::optional<std::string> deleted = row["deleted_at"]; // C++17 optionalGeneric lexical resolver
#include <charconv> // C++17
#include <sstream>
#include <string>
#include <type_traits> // C++11
class LexicalResolver {
std::string src_;
public:
explicit LexicalResolver(std::string s) : src_(std::move(s)) {} // C++11
// Fast path for arithmetic types using from_chars (C++17)
template <typename T>
operator T() const {
if constexpr (std::is_arithmetic_v<T>) { // C++17 if constexpr
T result{};
auto [ptr, ec] = std::from_chars( // C++17
src_.data(), src_.data() + src_.size(), result);
if (ec != std::errc{})
throw std::runtime_error("lexical_cast failed: " + src_);
return result;
} else {
T result{};
std::istringstream ss{src_};
if (!(ss >> result))
throw std::runtime_error("lexical_cast failed: " + src_);
return result;
}
}
operator std::string() const { return src_; } // identity, no parse
};
[[nodiscard]] LexicalResolver lexical_cast(std::string s) { // C++17
return LexicalResolver{std::move(s)};
}
int n = lexical_cast("123");
float f = lexical_cast("1.5");
// Works for any type with operator>> when not arithmeticBest Practices
Mark bool conversions explicit without exception. Implicit operator bool() fires in if, in arithmetic, and in comparisons β wherever any numeric type is expected. explicit operator bool() (C++11) forces callers to write static_cast<bool>(x), making the conversion intentional and visible.
Apply [[nodiscard]] to the factory function (C++17). A resolver that is constructed but never converted almost certainly indicates a missing assignment. [[nodiscard]] on the function that returns the resolver catches this at compile time.
Keep the resolver trivially movable. Resolvers are returned by value. They should hold at most a small string, a string_view, or a pointer-sized handle. Heap allocation inside a resolver is a design smell; the conversion operators should be where any real work happens.
Use noexcept precisely. Conversions over already-validated data (unwrapping a std::variant) can be noexcept. Conversions that parse text should not be; propagate the exception or return an std::optional.
Provide std::optional<T> overloads for nullable sources (C++17). When the backing data may be absent (a nullable DB column, a missing config key), an operator std::optional<T>() gives callers a clean way to handle absence without catching exceptions or checking a sentinel.
Common Pitfalls
Ambiguous conversions. If the compiler cannot pick between two equally viable conversion operators for a given target β most commonly int vs. long, or float vs. double β the expression is rejected as ambiguous. Design your operator set so each plausible target type maps to exactly one operator, or require callers to cast explicitly.
auto defeats the pattern. auto x = lexical_cast("42") deduces x as the resolver type, not int or any other concrete type. The conversion never fires. Document this prominently: the idiom requires a typed target (named variable, function argument with a known parameter type, or an explicit cast).
One user-defined conversion per chain. C++ permits at most one implicit user-defined conversion in a sequence. If the resolver produces an int but the target type only has a constructor that takes long, the second conversion (from int to long) is built-in and fires correctly. But if the target type has only a user-defined constructor from int, no second user-defined conversion can follow the resolver's operator int(), and the code fails to compile.
Lifetime hazards with views. If the resolver stores a std::string_view (C++17) or raw pointer into the source data, and that source is destroyed before the assignment triggers the conversion, the program has undefined behaviour. Prefer storing by value inside the resolver, or hold a std::shared_ptr to the source when lifetime is uncertain.
The Core Mechanism: Template Conversion Operator and to<T>
The canonical shape of the idiom is a single templated conversion operator that makes the proxy convertible to any type the caller names:
class Resolver {
std::string src_;
public:
explicit Resolver(std::string s) : src_(std::move(s)) {}
template <typename T>
operator T() const {
T result{};
std::istringstream ss{src_};
if (!(ss >> result))
throw std::runtime_error("conversion failed: " + src_);
return result;
}
// Explicit specialisation for the identity case
operator std::string() const { return src_; }
};The call site reads identically to a built-in assignment. The target variable's declared type drives instantiation of operator T():
int n = Resolver("42"); // T = int
double d = Resolver("3.14"); // T = doubleA common wrapper named to<T> makes the intent explicit without naming the type twice at the call site:
// Helper: named factory β target type is deduced, not passed
[[nodiscard]] Resolver to(std::string s) { return Resolver{std::move(s)}; }
// Caller still writes a typed assignment:
int port = to("8080");The lexical_cast variant in Boost (and its reimplementations) follow precisely this shape. The function is called with a source value, returns a resolver, and the assignment to a typed variable triggers operator T(). The key insight is that template argument deduction for a conversion operator uses the target of the conversion β the declared type of the variable being initialised β rather than the type of the initialiser expression.
Proxy With Multiple Concrete Targets
A single resolver can offer heterogeneous conversions. This is useful when a generic query returns a row or a key-value entry that might need to become several different concrete types at different call sites:
#include <string>
#include <variant> // C++17
#include <stdexcept>
struct Point { double x, y; };
class QueryResult {
// Stores whatever the DB returned; conversions unpack it
std::string raw_;
public:
explicit QueryResult(std::string s) : raw_(std::move(s)) {}
// Concrete arithmetic targets
operator int() const { return std::stoi(raw_); }
operator double() const { return std::stod(raw_); }
// String: identity
operator std::string() const { return raw_; }
// Custom aggregate: parse "x,y" into Point
operator Point() const {
auto comma = raw_.find(',');
if (comma == std::string::npos)
throw std::runtime_error("not a point: " + raw_);
return { std::stod(raw_.substr(0, comma)),
std::stod(raw_.substr(comma + 1)) };
}
// Explicit bool: is the value non-empty?
explicit operator bool() const { return !raw_.empty(); }
};
[[nodiscard]] QueryResult query(const std::string& key);
void use() {
int count = query("row_count"); // operator int()
double ratio = query("ratio"); // operator double()
std::string label = query("name"); // operator std::string()
Point origin = query("origin"); // operator Point()
// No explicit template argument at any call site
}The caller writes Point origin = query("origin") β the type appears exactly once, on the left-hand side, and the resolver dispatches to the right conversion without any get<Point>() syntax. Adding a new target type is an additive change: add one operator NewType() to the resolver and rebuild.
How This Differs From auto
auto deduces the type of a variable from the type of its initialiser. The return-type resolver works in the opposite direction: the declared type of the target drives which conversion fires. These two directions of deduction are complementary but non-overlapping.
// auto: type comes FROM the expression on the right
auto x = compute(); // x is whatever compute() returns
// Resolver: type comes FROM the declaration on the left
int n = lexical_cast("42"); // operator int() fires because n is intWriting auto with a resolver stops the pattern completely:
auto broken = lexical_cast("42");
// broken is LexicalResolver, not int
// No conversion fires; broken.src_ == "42" and nothing else happensThis is the same trap that trips auto with expression templates and lazy proxy types generally. Herb Sutter's GotW notes that auto result = matrix_a * matrix_b silently captures a dangling expression-template proxy when operator* returns a lazy object β the resolver faces exactly the same issue. The fix is identical: always assign to a typed target.
When the resolver holds a std::string_view or reference into source data and the source is a temporary, the danger compounds:
// DANGEROUS: the temporary string is destroyed before conversion fires
auto v = QueryResult(std::string("3.14")); // resolver holds reference to dead string
double d = v; // UB: dangling reference in operator double()Assigning immediately to a typed variable closes the window: double d = QueryResult("3.14") evaluates the conversion before the statement ends, well within the temporary's lifetime.
Resolvers and Aggregate Initialisation
A resolver can construct any aggregate type from the same call site, provided it knows enough information to fill all fields. This is especially useful for configuration structs whose shape differs per use site:
struct ServerConfig { std::string host; int port; bool tls; };
struct DbConfig { std::string dsn; int pool_size; };
class ConfigResolver {
// Holds a reference-counted map of keyβvalue pairs
std::shared_ptr<const std::map<std::string, std::string>> map_;
std::string prefix_;
std::string get(const std::string& k) const {
auto it = map_->find(prefix_ + "." + k);
return it != map_->end() ? it->second : std::string{};
}
public:
ConfigResolver(std::shared_ptr<const std::map<std::string, std::string>> m,
std::string prefix)
: map_(std::move(m)), prefix_(std::move(prefix)) {}
operator ServerConfig() const {
return { get("host"),
std::stoi(get("port")),
get("tls") == "true" };
}
operator DbConfig() const {
return { get("dsn"),
std::stoi(get("pool_size")) };
}
};
[[nodiscard]] ConfigResolver getConfig(const std::string& section);
void init() {
ServerConfig server = getConfig("server"); // operator ServerConfig()
DbConfig db = getConfig("db"); // operator DbConfig()
// Same call; different struct; no explicit type at the call
}The std::shared_ptr here satisfies the lifetime requirement: the backing map outlives any resolver copy, so the reference-through-pointer inside operator ServerConfig() is always valid.
Pitfalls in Depth
Proxy Lifetime and Dangling References
The most serious pitfall arises when the resolver stores a non-owning view of its source rather than a copy. A std::string_view is attractive because it avoids allocation, but it must not outlive the object it views:
// WRONG: string_view into a temporary
class ViewResolver {
std::string_view src_; // non-owning
public:
explicit ViewResolver(std::string_view s) : src_(s) {}
operator int() const {
int r{}; std::from_chars(src_.data(), src_.data()+src_.size(), r); return r;
}
};
ViewResolver make(const char* s) { return ViewResolver{s}; }
// OK: temporary char array lives long enough for the conversion on same line
int n = make("42");
// WRONG: resolver is stored; the string literal is fine but a temporary std::string is not
std::string tmp = get_string_from_somewhere();
ViewResolver v{tmp}; // src_ points into tmp
tmp.clear(); // reallocates or modifies β src_ now dangles
int bad = v; // UBThe safe rule: if the resolver is ever stored in a named variable (rather than converted immediately), it must own its data.
Implicit Conversion Chains: Only One UDC Permitted
The C++ standard (dcl.init, over.best.ics) limits an implicit conversion sequence to at most one user-defined conversion. A resolver's operator T() counts as that one UDC. Any further conversion in the same chain must be a standard (built-in) conversion:
struct Celsius { explicit Celsius(double d) : val(d) {} double val; };
class TempResolver {
double raw_;
public:
explicit TempResolver(double v) : raw_(v) {}
operator double() const { return raw_; } // UDC #1
};
// Celsius has an EXPLICIT constructor from double β requires another UDC
Celsius c = TempResolver(98.6); // ERROR: two UDCs in one sequence
// Fix: make the resolver produce Celsius directly
class TempResolver2 {
double raw_;
public:
explicit TempResolver2(double v) : raw_(v) {}
operator Celsius() const { return Celsius{raw_}; } // single UDC, explicit ctor OK
};
Celsius c2 = TempResolver2(98.6); // OKThe workaround is to add a dedicated operator TargetType() for each user-defined target that cannot be reached through a standard conversion from an arithmetic type.
When to Use explicit Conversion Operators
explicit operator T() (C++11) requires a cast at the call site:
explicit operator bool() const; // caller must write static_cast<bool>(r)
explicit operator int() const; // caller must write static_cast<int>(r)Mark a conversion explicit when it is lossy, when it might fire in surprising arithmetic contexts, or when the target type is a common destination for accidental conversions. The standard library itself applies this rule: std::optional, std::expected, and smart pointers all use explicit operator bool(). For a resolver that produces bool, int, or float from text, prefer explicit for bool and leave numeric conversions implicit β a narrowing warning from the compiler (-Wconversion) can catch the lossy cases without requiring a full explicit barrier.
Modern Context: Where the Pattern Still Excels
std::expected<T, E> (C++23) and monadic operations (and_then, or_else, transform) handle the error propagation use case that once prompted conversion-operator tricks returning sentinel values. Structured bindings (C++17) allow auto [val, err] = parse(...) which is cleaner than a resolver for multi-value returns. These features reduce but do not eliminate the pattern's domain.
The return-type resolver remains the clearest solution in three remaining scenarios:
1. Untyped external sources with many possible target types. Environment variables, INI files, YAML scalars, and JSON primitive fields are all strings at the storage layer. A single resolver over a std::string with four or five operator T() overloads eliminates the need for callers to choose between cfg.get_int("key"), cfg.get_string("key"), and cfg.get_double("key"). std::expected helps with error handling but does not eliminate the need to name the target type at the call site.
2. Lazy, expensive construction with a single resolution point. When constructing the value is costly (parsing a large JSON tree, opening a file, running a database query) and the resolver can defer that work until operator T() fires, the idiom provides clean lazy evaluation without futures or coroutines. std::expected would require the work to be done before wrapping the result.
3. Generic container population (the getRandomN pattern from Wikibooks). A single factory call that populates any standard container β std::vector, std::list, std::set β through a template operator Container() remains more ergonomic than any combination of std::expected or structured bindings. The conversion operator is instantiated with the container type and can call insert() uniformly regardless of container category.
class RandomFill {
std::size_t n_;
public:
explicit RandomFill(std::size_t n) : n_(n) {}
template <typename Container>
operator Container() const {
Container c;
std::mt19937 rng{std::random_device{}()};
std::uniform_int_distribution<int> dist(1, 100);
std::generate_n(std::inserter(c, c.end()), n_,
[&]{ return dist(rng); });
return c;
}
};
[[nodiscard]] RandomFill random_n(std::size_t n) { return RandomFill{n}; }
std::vector<int> v = random_n(10); // operator vector<int>()
std::set<int> s = random_n(5); // operator set<int>()
std::list<int> l = random_n(8); // operator list<int>()std::inserter works for both sequence and associative containers, making the single template body fully generic across the standard container taxonomy.
See Also
- Type Erasure β hiding concrete type behind a stable interface; resolvers and type-erased wrappers are frequently composed to build any-like access patterns
- Overload Pattern β combining callable objects into a single overload set; complements resolvers when dispatch on input type is also needed
- Builder β another deferred-result idiom where the final object is assembled incrementally rather than resolved at a single assignment