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.
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