C++ User-Defined Literals: Raw vs Cooked Operators, Unit Types and consteval Validation
Key takeaways
A user-defined literal is a function call the compiler inserts for tokens like 10_km or "FF5733"_rgb. This article covers which operator signature gets picked, the raw and template forms, strong unit types, standard library suffixes, and consteval literals that turn bad input into a compile error, all checked with g++ 10.3.
A user-defined literal (UDL) is a suffix on a literal token, such as 10_km, 2.5_deg or "FF5733"_rgb, that makes the compiler call a function named operator""_suffix. The feature has existed since C++11, and the standard library uses it for 100ms, "text"s and 3.0i. The syntax is small, but there are a few non-obvious rules about which operator gets called and what it receives. They explain most of the compile errors people hit. All output below comes from g++ 10.3 with -std=c++20.
What the compiler actually calls
For each literal kind there is a fixed list of allowed parameter lists:
| Literal | Cooked form (value already parsed) | Fallbacks |
|---|---|---|
Integer 42_x | operator""_x(unsigned long long) | raw (const char*), template <char...> |
Floating 4.2_x | operator""_x(long double) | raw (const char*), template <char...> |
String "ab"_x | operator""_x(const char*, std::size_t) | C++20: template <class-type NTTP> |
Character 'a'_x | operator""_x(char) (and wchar_t, char8_t, char16_t, char32_t) | none |
Anything else is rejected at the declaration:
constexpr int operator""_x(int v) { return v * 2; }
// error: 'constexpr int operator""_x(int)' has invalid argument list
The part that trips people up is that lookup is driven by the literal kind, not by conversions. Define only the floating-point version and use it with an integer:
struct Meters { double value; };
constexpr Meters operator""_km(long double v) { return {static_cast<double>(v * 1000)}; }
Meters a = 1.5_km; // OK
Meters b = 10_km; // error
error: unable to find numeric literal operator 'operator""_km'
note: use '-fext-numeric-literals' to enable more built-in suffixes
The note about -fext-numeric-literals is a red herring. It concerns GNU built-in suffixes, not your operator. The compiler looked for operator""_km(unsigned long long), then the raw and template forms, and found none. It will not convert 10 to long double for you. Units that accept both 10_km and 1.5_km need two overloads.
Two related surprises:
- There are no negative literals.
-5_kmis-(operator""_km(5)). If the operator returns a class, g++ reportsno match for 'operator-' (operand type is 'Meters')until you define unary minus. - Overflow is only a warning. With the cooked integer form,
99999999999999999999_nproduceswarning: integer literal exceeds range of 'long long unsigned int' type [-Woverflow]and the program compiles with a truncated value. If a literal must be validated, use the raw or template form below and check the digits yourself.
Why the underscore matters
Suffixes that do not start with _ are reserved for the standard. g++ does not reject them, it warns:
constexpr unsigned long long operator""km(unsigned long long v) { return v * 1000; }
warning: literal operator suffixes not preceded by '_' are reserved for future standardization [-Wliteral-suffix]
That program still compiles and runs, which makes the warning easy to ignore. Don’t. The whole standard library set (s, ms, h, i, sv, and in C++20 y and d for calendar types) lives in this reserved space. If a future standard adds your suffix, your code will likely become ambiguous with it or change meaning. The standard already gives s two meanings, seconds for numbers and std::string for strings, which shows how crowded that namespace is.
There is a second, subtler rule about spacing. In the older spelling operator"" _KB (with a space), the suffix is parsed as an ordinary identifier, and identifiers beginning with an underscore followed by an uppercase letter are reserved everywhere. Writing it without the space, operator""_KB, avoids that problem, and C++23 deprecates the spaced form. New code should always use the no-space form.
Raw vs cooked: what the operator receives
A cooked operator gets the value the compiler already parsed. A raw operator (const char* parameter, numeric literals only) gets the characters of the token exactly as written:
#include <cstdio>
void operator""_raw(const char* s) { std::printf("raw : \"%s\"\n", s); }
void operator""_cooked(unsigned long long v) { std::printf("cooked : %llu\n", v); }
void operator""_rawf(const char* s) { std::printf("rawf : \"%s\"\n", s); }
int main() {
0x1F_raw;
0x1F_cooked;
1'000'000_raw;
0.1_rawf;
1e400_rawf;
}
raw : "0x1F"
cooked : 31
raw : "1'000'000"
rawf : "0.1"
rawf : "1e400"
The raw form sees the 0x prefix and the digit separators. The cooked form only sees the parsed number. That is useful when the text itself matters. A decimal fixed-point money type, for example, wants "0.1" and not the nearest binary long double, which is not exactly 0.1. 1e400 would overflow a double but reaches the raw operator untouched. A raw operator can be constexpr and validate its text in constant expressions too. The template form below goes one step further and makes each character a template argument, so the digits are available at the type level.
The template form: compile-time digit checking
A numeric literal operator can also be a template with an empty parameter list, receiving each character as a template argument:
#include <cstdio>
#include <initializer_list>
template <char... Cs>
constexpr unsigned operator""_bits() {
unsigned v = 0;
for (char c : {Cs...}) {
if (c == '\'') continue; // allow digit separators
if (c != '0' && c != '1') throw "_bits accepts only 0 and 1";
v = v * 2 + (c - '0');
}
return v;
}
static_assert(1010_bits == 10);
static_assert(1111'0000_bits == 240);
int main() {
std::printf("%u\n", 1101_bits); // 13
constexpr unsigned x = 1201_bits; // error
}
The last line fails with error: expression '<throw-expression>' is not a constant expression, because evaluating it at compile time reaches the throw. (Note the #include <initializer_list>. Without it g++ refuses the for (char c : {Cs...}) loop with “deducing from brace-enclosed initializer list requires ‘#include <initializer_list>’”.) Since C++14 has built-in 0b1010 literals, this particular operator is a teaching example. The same technique works for base-36 IDs, fixed-point numbers, or anything where each digit needs checking.
The catch is that validation only happens when the result is required at compile time. unsigned y = 1201_bits; in a non-constexpr context compiles fine and throws at runtime. For string literals, C++20 has a cleaner answer.
consteval literals: typos become compile errors
A consteval literal operator must be evaluated at compile time on every use, so any path that is not a valid constant expression becomes a diagnostic at the call site:
#include <cstddef>
#include <cstdint>
#include <cstdio>
struct Rgb { std::uint8_t r, g, b; };
constexpr int hexDigit(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
throw "invalid hex digit in _rgb literal"; // reaching this is not a constant expression
}
constexpr std::uint8_t hexByte(const char* p) {
return static_cast<std::uint8_t>(hexDigit(p[0]) * 16 + hexDigit(p[1]));
}
consteval Rgb operator""_rgb(const char* s, std::size_t len) {
if (len != 6) throw "_rgb literal needs exactly 6 hex digits";
return {hexByte(s), hexByte(s + 2), hexByte(s + 4)};
}
int main() {
Rgb orange = "FF5733"_rgb;
std::printf("R:%d G:%d B:%d\n", orange.r, orange.g, orange.b); // R:255 G:87 B:51
}
Now add a typo on the line after the printf, Rgb typo = "FF57G3"_rgb;, and g++ shows the whole evaluation chain:
consteval.cpp:26:16: in 'constexpr' expansion of 'operator""_rgb(((const char*)"FF57G3"), 6)'
consteval.cpp:20:48: in 'constexpr' expansion of 'hexByte((s + 4))'
consteval.cpp:15:46: in 'constexpr' expansion of 'hexDigit(((int)((char)(* p))))'
consteval.cpp:11:5: error: expression '<throw-expression>' is not a constant expression
11 | throw "invalid hex digit in _rgb literal"; // reaching this is not a constant expression
The string after throw is never thrown. It is there so the source line quoted in the diagnostic explains itself. This is the most valuable use of UDLs I know of. Configuration values, color codes and format strings that used to fail at startup now fail in the build.
One thing I ran into when first writing this: my initial version put the byte parsing in a lambda inside the consteval operator. g++ 10.3 rejected it with 's' is not a constant expression, because the lambda itself is not consteval and cannot pass its parameter on to an immediate function. Moving the helpers into plain constexpr functions, as above, avoids the problem. C++23’s P2564 relaxes this, but older compilers will keep producing that confusing error.
A strong unit type, not a converted number
Many UDL tutorials write operator""_km that returns long double meters. That makes the code read better but adds no safety, since setRange(10_km) and setRange(10) both compile and mean different things. The payoff comes when the operator returns a distinct type:
#include <cstdio>
namespace units {
struct Meters {
double value;
friend constexpr Meters operator+(Meters a, Meters b) { return {a.value + b.value}; }
};
namespace literals {
constexpr Meters operator""_m(long double v) { return {static_cast<double>(v)}; }
constexpr Meters operator""_m(unsigned long long v) { return {static_cast<double>(v)}; }
constexpr Meters operator""_km(long double v) { return {static_cast<double>(v * 1000)}; }
constexpr Meters operator""_km(unsigned long long v) { return {static_cast<double>(v * 1000)}; }
} // namespace literals
} // namespace units
void setRange(units::Meters r) { std::printf("range = %g m\n", r.value); }
int main() {
using namespace units::literals;
setRange(1.5_km + 250_m); // range = 1750 m
setRange(2_km); // range = 2000 m
// setRange(2000); // error: no conversion from int to Meters
}
The usual failure mode here is less about syntax and more about what happens at API boundaries. Any function that takes a bare double distance means the unit is only documented in a comment. Mixing meters and kilometers (or feet and meters) then compiles without a complaint, and you find out from wrong results. Once a boundary takes Meters, a bare number at a call site becomes a compile error, and every value needs a suffix that states its unit. In my experience, pushing the strong type into function signatures makes the bug impossible. Adding literals to call sites alone does not.
Put the operators in a nested literals namespace, the way the standard library does. Callers can then write using namespace units::literals; inside a function without pulling the rest of units into scope, and a header never forces the suffixes on anyone.
For serious dimensional analysis (m/s times s giving m, and so on), use an existing library such as mp-units or Boost.Units rather than growing your own. The template machinery gets large quickly.
Standard library literals and their traps
#include <chrono>
#include <cstdio>
#include <string>
#include <string_view>
#include <type_traits>
int main() {
using namespace std::literals; // chrono, string, string_view, complex
std::string a = "abc\0def"; // const char* constructor stops at '\0'
auto b = "abc\0def"s; // length comes from the literal
auto c = "abc\0def"sv;
std::printf("%zu %zu %zu\n", a.size(), b.size(), c.size());
auto timeout = 1500ms;
auto total = 2s + timeout; // common type: milliseconds
std::printf("%lld ms\n", static_cast<long long>(total.count()));
auto d = 2.5s; // floating-point seconds
static_assert(std::is_same_v<decltype(d)::rep, long double>);
std::printf("%g s\n", static_cast<double>(d.count()));
}
3 7 7
3500 ms
2.5 s
Things to notice:
"..."sand"..."svget the length from the string literal operator’ssize_tparameter, so embedded null bytes survive. Theconst char*constructor stops at the first'\0'.2.5sis aduration<long double>. My first version of this example passedd.count()straight toprintf("%g")and printed2.49725e-312, because%gexpects adouble.-Wallcatches it (format '%g' expects argument of type 'double', but argument 2 has type ... 'long double'). Streams orstd::formatavoid the issue entirely.- Namespace choice matters. With only
using namespace std::chrono_literals;, the lineauto x = "hi"s;fails withno matching function for call to 'operator""s<"hi">()'because the stringslives instd::string_literals.std::literalspulls in both.using namespace std::chrono;also brings in the chrono suffixes.
The chrono literals article covers the time suffixes and C++20 calendar literals in more depth.
Compile-time string hashing for switch
switch needs integral constants, and a constexpr string literal operator can provide them:
#include <cstddef>
#include <cstdio>
#include <string_view>
constexpr std::size_t fnv1a(std::string_view s) {
std::size_t h = 14695981039346656037ull;
for (char c : s) { h ^= static_cast<unsigned char>(c); h *= 1099511628211ull; }
return h;
}
constexpr std::size_t operator""_hash(const char* s, std::size_t n) { return fnv1a({s, n}); }
void dispatch(std::string_view event) {
switch (fnv1a(event)) {
case "click"_hash: std::puts("click"); break;
case "hover"_hash: std::puts("hover"); break;
default: std::puts("unknown");
}
}
dispatch("hover") prints hover, dispatch("drag") prints unknown. The compiler does protect you in one direction: if two case labels hash to the same value, you get a duplicate-case error. It does not protect you from a runtime string whose hash collides with a label. For untrusted input, compare the string again inside the matched case.
When not to write one
UDLs are best for values with a unit or a format: distances, sizes (4_KiB), angles, colors, validated IDs. They make code worse when the suffix hides a non-trivial operation ("config.json"_load that reads a file), and when a named function would be as short and clearer. Every new suffix is also one more thing a reader must look up. If a codebase has three different _s operators in three namespaces, using namespace directives decide which one a line means, which is exactly the ambiguity literals were supposed to remove.