Consider this function signature:
Post load_post(const std::string& title,
const std::string& slug,
const std::string& series);
Nothing stops a caller from accidentally passing arguments in the wrong order. load_post(slug, title, series) compiles fine and produces a broken post that's hard to debug. Loom solves this with strong types.
The StrongType Wrapper
template<typename T, typename Tag>
class StrongType {
public:
explicit StrongType(T value) : value_(std::move(value)) {}
const T& get() const noexcept { return value_; }
bool empty() const
requires requires(const T& v) { v.empty(); }
{ return value_.empty(); }
bool operator==(const StrongType& other) const { return value_ == other.value_; }
bool operator!=(const StrongType& other) const { return value_ != other.value_; }
bool operator< (const StrongType& other) const { return value_ < other.value_; }
private:
T value_;
};
Tag is a phantom type — it exists only to make each instantiation distinct. StrongType<std::string, SlugTag> and StrongType<std::string, TitleTag> are different types even though both wrap std::string.
All Strong Types in Loom
// types.hpp
struct PostIdTag {};
struct TitleTag {};
struct SlugTag {};
struct TagTag {};
struct ContentTag {};
struct SeriesTag {};
using PostId = StrongType<std::string, PostIdTag>;
using Title = StrongType<std::string, TitleTag>;
using Slug = StrongType<std::string, SlugTag>;
using Tag = StrongType<std::string, TagTag>;
using Content = StrongType<std::string, ContentTag>;
using Series = StrongType<std::string, SeriesTag>;
Now Post is:
struct Post {
PostId id;
Title title;
Slug slug;
Content content; // rendered HTML
std::vector<Tag> tags;
std::chrono::system_clock::time_point published;
bool draft;
std::string excerpt;
std::string image;
int reading_time_minutes;
Series series;
std::filesystem::file_time_type mtime;
};
And load_post becomes:
Post load_post(const std::string& path,
const std::string& series_name,
int& counter,
const std::string& content_dir);
Within that function, you can't accidentally assign slug to title:
Title t = Title("My Post");
Slug s = Slug("my-post");
t = s; // compile error: cannot convert Slug to Title
The C++20 requires Clause
The empty() method uses a C++20 requires clause on a member function:
bool empty() const
requires requires(const T& v) { v.empty(); }
This says: "this method only exists if T has an empty() member." For StrongType<std::string, ...>, std::string has empty(), so the method is available. For a hypothetical StrongType<int, ...>, it would be silently excluded from the type.
The outer requires gates the member. The inner requires(...) is a requires expression — a compile-time boolean that checks whether the expression v.empty() is valid. This is a compound requires expression.
Without C++20:
// C++17 SFINAE equivalent — harder to read, harder to write
template<typename U = T, std::enable_if_t</* has_empty<U> */> = 0>
bool empty() const { return value_.empty(); }
Why explicit on the Constructor
explicit StrongType(T value) : value_(std::move(value)) {}
explicit prevents implicit conversion. Without it:
void render_tag_page(const Tag& tag) { ... }
render_tag_page("cpp"); // would compile without explicit — wrong type, wrong place
With explicit, the call fails to compile. You must write render_tag_page(Tag("cpp")), making the intent visible.
std::move(value) moves the argument into the wrapper rather than copying. For strings, this is zero cost if the caller passes an rvalue — the string's internal buffer is transferred, no allocation.
Using .get() to Extract Values
When you need the underlying string — to build a URL, compare against a raw string, pass to a C API — you call .get():
std::string url = "/post/" + post.slug.get();
std::string heading = post.title.get();
cache->pages["/tag/" + tag.get()] = make_cached(...);
The .get() suffix is intentionally a tiny friction. It makes raw-value access visible in code review, nudging you toward strong-typed APIs at boundaries.
Comparison and Sorting
Strong types support ==, !=, and <. This means they work naturally in standard algorithms:
// Sort posts by title
std::sort(posts.begin(), posts.end(),
[](const Post& a, const Post& b) { return a.title < b.title; });
// Find a post by slug
auto it = std::find_if(posts.begin(), posts.end(),
[&](const Post& p) { return p.slug == Slug(slug_str); });
// Use Tag in a std::set for deduplication
std::set<Tag> unique_tags;
for (const auto& post : posts)
unique_tags.insert(post.tags.begin(), post.tags.end());
The < operator delegates to T's <, so Tag("cpp") < Tag("rust") is a string comparison. This is correct for alphabetical sorting; it's not meaningful as a domain ordering, but that's fine because Tag values are just labels.
What Strong Types Don't Give You
Strong types catch mixing of values with different semantic roles. They don't validate the content of a value. Slug("this has spaces!") is a valid Slug that will produce a broken URL.
For that, you'd add validation in the constructor — or use a separate validation step at the content-loading boundary. Loom takes the latter approach: slugs are validated/normalised when loading content from disk, before they become Slug values.
The Bigger Pattern: Making Illegal States Unrepresentable
Strong types are one instance of a broader C++ idiom: encode invariants in types so the compiler enforces them.
Other examples in Loom:
const Site¶meter to renderers — the site is read-only during renderingstd::shared_ptr<const SiteCache>— the cache is immutable once builtstd::unique_ptr<TrieNode>— each trie node has exactly one owner
The pattern is: if something should not be done, make the type system prevent it rather than the programmer remember not to.