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& parameter to renderers — the site is read-only during rendering
  • std::shared_ptr<const SiteCache> — the cache is immutable once built
  • std::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.