static-string
June 30, 2026 ยท View on GitHub
A header-only, constexpr compile-time string for C++17.
What it is
static_string builds and concatenates fixed strings entirely at compile time.
You write static_string::string("Hello, ") + static_string::string("world") + '!' and the result, including its length, is a constant computed by the
compiler. It converts implicitly to const char* and std::string_view (and
explicitly to std::string), so a value you assemble at compile time drops
straight into ordinary runtime code.
When to use it / when not
Use it when you want to assemble fixed strings without paying for it at runtime:
table headers, ANSI color escape sequences, compile-time hashing seeds, labels
whose pieces are known when you compile. Everything happens in constexpr
context with no heap allocation and no runtime string work, and the conversions
let the finished value flow into normal APIs.
Skip it if your strings are runtime data. The length is part of the type
(static_string<13, char_array> is a different type from
static_string<14, char_array>), so anything you concatenate has to be known to
the compiler. Long concatenations also grow template instantiations, which costs
compile time rather than runtime. For dynamic strings, use std::string.
Install
Header-only. Copy static_string.hh somewhere on your include path and include
it:
#include "static_string.hh"
With CMake there's an INTERFACE target (static_string) you can link against.
To pull it in via FetchContent:
include(FetchContent)
FetchContent_Declare(
static_string
GIT_REPOSITORY https://github.com/Kronuz/static-string.git
GIT_TAG main
)
FetchContent_MakeAvailable(static_string)
target_link_libraries(your_target PRIVATE static_string)
The static_string target adds the include directory and requests cxx_std_17.
There is no third-party dependency. When compiled as C++20 (with <format>), an
optional std::formatter specialization is provided so static_string values
work directly in std::format(...); define STATIC_STRING_NO_FORMAT to turn it
off. It is only a convenience, since static_string already converts to
std::string_view, which std::format formats directly.
Usage
All examples assume #include "static_string.hh" and a namespace ss = static_string; alias.
Compile-time concatenation
namespace ss = static_string;
// Literals and a single char, concatenated at compile time.
constexpr auto greeting = ss::string("Hello, ") + ss::string("world") + '!';
static_assert(greeting.size() == 13, "size");
// greeting.c_str() == "Hello, world!"
// You can mix in raw string literals on either side of +.
constexpr auto path = ss::string("/usr") + "/local";
// std::string_view(path) == "/usr/local"
Integers and chars to strings
// Integer -> string, computed by the compiler.
constexpr auto n = ss::to_string<2025>();
// n.c_str() == "2025"
// A single char as a one-element string.
constexpr auto x = ss::char_to_string<'x'>();
// x.c_str() == "x"
The string(...) factory
// string() wraps a raw literal and deduces its length (no null terminator).
constexpr auto s = ss::string("abc"); // static_string<3, literal_ref>
static_assert(s.size() == 3, "size");
// A single char goes through the owning char_array flavor.
constexpr auto c = ss::string('z'); // static_string<1, char_array>
Comparisons
// == and < are constexpr and compare across both flavors.
static_assert(ss::string("abc") == ss::string("abc"), "eq");
static_assert(!(ss::string("abc") == ss::string("abd")), "neq");
static_assert(ss::string("abc") < ss::string("abd"), "lt");
Conversions
constexpr auto label = ss::string("count=") + ss::to_string<42>();
const char* cstr = label; // implicit operator const char*
std::string_view sv = label; // implicit operator string_view
std::string owned = std::string(label); // explicit operator std::string
// label.c_str(), label.data(), label.size(), label.length() also work.
API reference
Everything lives in namespace static_string in static_string.hh.
The value type
template <std::size_t N, typename T>
class static_string;
N is the string length without the null terminator. T is a tag that
picks one of two flavors (the unspecialized primary template is a hard error,
static_string.hh:60):
string_literal_ref<N>isstatic_string<N, literal_ref>(static_string.hh:66). It is a non-owning wrapper over a string literal reference,const char (&)[N + 1](static_string.hh:78). No copy, no storage of its own. Produced bystring("literal").string_char_array<N>isstatic_string<N, char_array>(static_string.hh:70). It owns a stack-stored, null-terminatedchar[N + 1](static_string.hh:155). Every concatenation result is achar_array.
A string_literal_ref<N> converts to string_char_array<N> by copying its
characters into the owning buffer (static_string.hh:176).
Factories
template <std::size_t N_PLUS_1>
constexpr auto string(const char (&s)[N_PLUS_1]); // -> string_literal_ref<N_PLUS_1 - 1>
constexpr auto string(const char ch); // -> string_char_array<1>
string(...) wraps a raw string literal as a non-owning string_literal_ref
and deduces the length, dropping the null terminator (static_string.hh:221).
The char overload produces a one-element owning string (static_string.hh:227).
template <int num> constexpr auto to_string(); // -> string_char_array<digits>
template <char ch> constexpr auto char_to_string(); // -> string_char_array<1>
to_string<int>() renders a non-negative integer to its decimal digits at
compile time (static_string.hh:137); to_string<0>() is "0".
char_to_string<char>() makes a one-element owning string from a char
(static_string.hh:144).
Concatenation operators
operator+(static_string, static_string) // -> string_char_array<NL + NR>
operator+(const char (&)[...], static_string)
operator+(static_string, const char (&)[...])
operator+(char, static_string)
operator+(static_string, char)
All five overloads (static_string.hh:246-269) return an owning
string_char_array whose size is the sum of the operand sizes, computed at
compile time. Operands may be either flavor, raw literals, or single chars in
any combination.
Two extra operator+ overloads (static_string.hh:271, :279) take a
static_string on one side and an arbitrary other type on the other and return
a runtime std::string. These are the escape hatch for mixing a compile-time
string with a runtime one; they allocate and are not constexpr.
Accessors and conversions
Both flavors expose the same interface (static_string.hh:84-113 and
:185-215):
constexpr std::size_t size() const; // == N
constexpr std::size_t length() const; // == N
constexpr const char* c_str() const; // null-terminated
constexpr const char* data() const;
constexpr char operator[](std::size_t i) const;
template <typename OT, std::size_t ON>
constexpr bool operator==(const static_string<ON, OT>&) const;
template <typename OT, std::size_t ON>
constexpr bool operator<(const static_string<ON, OT>&) const;
constexpr operator const char* () const; // implicit
constexpr operator std::string_view() const; // implicit
explicit operator std::string() const; // explicit
== and < are templated over the other operand's N and tag, so they compare
across both flavors. == short-circuits on a length mismatch; < is plain
lexicographic with the shorter string ordering first on a common prefix.
Build & test
Header-only, so there's nothing to compile for use. To run the smoke test:
c++ -std=c++17 -I. test/test.cc -o test/test && ./test/test
# or with CMake:
cmake -B build && cmake --build build && ctest --test-dir build
The test (test/test.cc) checks compile-time concatenation, to_string<2025>(),
raw-literal mixing, and constexpr comparison. It uses assert, so build
without NDEBUG. Requires a C++17 compiler.
Notes & caveats
- The length is baked into the type.
static_string<3, ...>andstatic_string<4, ...>are distinct types, so you generally store results inauto(ideallyconstexpr auto) rather than naming the type. string("literal")is a non-owningstring_literal_ref: it holds a reference to the literal's storage and copies nothing. The result of any+, by contrast, is an owningstring_char_arraywith its own stack buffer.to_string<int>()handles non-negative integers; there is no minus-sign handling.- Long concatenation chains grow template instantiations and slow the compiler. This is a compile-time cost, not a runtime one.
- The
constexpr_assertchecks (bounds, null termination, size match) fire as runtimeasserts outside constant evaluation and are compiled out underNDEBUG(static_string.hh:53). - The optional
std::formatter(C++20) is pure convenience; the implicitstd::string_viewconversion already letsstd::formatprint astatic_string.
Examples
examples/demo.cc is a runnable tour, built by a top-level
CMake build:
cmake -B build && cmake --build build && ./build/static_string_demo
It folds a string("hello") + " " + string("world") concatenation into one
constant (whose length is fixed in its type), uses static_assert to prove the
equality and lexicographic comparison ran in the compiler, renders integers with
to_string<N>() (including a signed value), stitches a real \e[38;2;255;0;0m
ANSI escape the way ansi_color.hh does, and then crosses into runtime to hand
the same bytes to string_view, std::string, and std::format.
Provenance
Extracted from Xapiand, where it is vendored
and underpins ansi_color.hh (the compile-time colors) and the compile-time
hashing. The literal_ref core derives from Andrzej Krzemienski's constexpr
string work and is substantially extended here.
License
Boost Software License 1.0. Copyright (c) 2015-2019 Dubalu LLC and Copyright (c) 2017 Andrzej Krzemienski. See LICENSE.