Eliminating Primitive Obsession in C# with Semantic Types

Most C# codebases harbor a code smell so pervasive that we barely register it: primitive obsession. We pass string where we mean an email address, a file path, a repository name, or a URL. We pass double where we mean meters, kilograms, or seconds. The type system treats all of these identically, which means the compiler can't help us when we accidentally pass a username where a password was expected.

This post examines what primitive obsession costs us in practice, and how a technique built on the Curiously Recurring Template Pattern (CRTP) can eliminate entire categories of bugs at compile time.

When Everything Is a String

Consider a method signature that turns up in just about any codebase:

public void CloneRepository(string name, string remotePath, string localPath)
{
    // ...
}

Nothing prevents a caller from writing:

// Oops — arguments in the wrong order
CloneRepository(remotePath, name, localDirectory);

The compiler accepts this without complaint. Every type matches: string, string, string. The bug won't surface until runtime, and possibly not until production.

This isn't a contrived example. Whenever a method accepts two or more parameters of the same primitive type, the door is open for transposition bugs. The more parameters there are, the worse the odds become.

Paths

Paths are an especially treacherous case. A string can represent an absolute path, a relative path, a directory path, or a file path. The compiler has no awareness of which one the caller intended:

string configDir = @"C:\app\config";
string logFile = @"logs\app.log";

// This compiles fine but is semantically wrong —
// we're treating a relative path as an absolute one
File.Delete(logFile);

// And this silently produces garbage —
// combining two absolute paths doesn't do what you'd expect
string combined = Path.Combine(configDir, @"C:\other\path");
// Result: "C:\other\path" — the first argument is silently discarded

Semantic Types

The fix is to make the type system carry the semantic meaning that string discards. Instead of bare string, we define distinct types that the compiler can differentiate:

public sealed record GitRepositoryName : SemanticString<GitRepositoryName> { }
public sealed record GitRepositoryWebURI : SemanticString<GitRepositoryWebURI> { }
public sealed record GitRepositoryRemotePath : SemanticString<GitRepositoryRemotePath> { }

Each of these is a one-line declaration, yet it creates a completely distinct type in the eyes of the compiler. The method signature becomes both self-documenting and compiler-enforced:

public void CloneRepository(
    GitRepositoryName name,
    GitRepositoryRemotePath remotePath,
    AbsoluteDirectoryPath localPath)
{
    // ...
}

Try transposing the arguments now. The compiler will refuse to build.

This is the approach used in the ktsu.dev Semantics library. The GitRepository class from the ktsu.dev ecosystem shows how this looks in practice:

public class GitRepository
{
    public GitRepositoryName Name { get; init; } = new();
    public GitRepositoryWebURI WebURI { get; init; } = new();
    public GitRepositoryRemotePath RemotePath { get; init; } = new();
    public AbsoluteDirectoryPath LocalPath { get; init; } = new();

    public bool IsCloned => Directory.Exists(LocalPath);
}

Every property carries a distinct type. There's no way to accidentally assign a GitRepositoryName to a GitRepositoryWebURI. The compiler won't allow it.

The Curiously Recurring Template Pattern

The foundation of this approach is a base class that employs CRTP, sometimes called the "self-referencing generic" pattern in C#:

public abstract record SemanticString<TDerived> : ISemanticString
    where TDerived : SemanticString<TDerived>
{
    public string WeakString { get; init; } = string.Empty;

    // Factory method — creates an instance of any semantic string type
    public static TDest Create<TDest>(string? value)
        where TDest : SemanticString<TDest>
    {
        TDest newInstance = FromStringInternal<TDest>(value);
        return PerformValidation(newInstance);
    }

    // Implicit conversion back to string — no ceremony needed
    public static implicit operator string(SemanticString<TDerived>? value)
        => value?.WeakString ?? string.Empty;
}

The TDerived type parameter is what makes this work. Because the base class knows the exact derived type, factory methods like Create can return the correct concrete type without requiring a cast. Creating a semantic string is straightforward:

var repoName = GitRepositoryName.Create("my-project");

And because of the implicit conversion to string, a semantic string passes to any API that expects a regular string with no friction:

Console.WriteLine($"Cloning {repoName}...");  // Just works

The property name WeakString is deliberately chosen. Accessing the raw string value is an escape hatch that breaks type safety, and the name ensures that cost is visible in every code review.

The .As<T>() Extension Method

While the Create factory method works well, the library provides something more fluent, an As<T>() extension method defined directly on string:

public static class SemanticStringExtensions
{
    public static TDerived As<TDerived>(this string? value)
        where TDerived : SemanticString<TDerived>
        => SemanticString<TDerived>.Create<TDerived>(value);
}

This enables the following syntax:

var repoName = "my-project".As<GitRepositoryName>();

The string literal reads first, followed by the type it's being cast into. It flows like natural language, "take this string as a GitRepositoryName." Compare the two approaches:

// Factory method — type comes first, then the value
var repoName = GitRepositoryName.Create("my-project");

// Extension method — value comes first, then the type
var repoName = "my-project".As<GitRepositoryName>();

Both produce identical results. Canonicalization and validation run regardless of which path the call takes. But .As<T>() reads particularly well when chaining or working inline:

repository.Name = config["repo-name"].As<GitRepositoryName>();

The same .As<T>() pattern also supports converting between semantic types. Given one semantic string that needs to be viewed through the lens of another type, the instance method on the base class handles the conversion:

var repoName = "my-project".As<GitRepositoryName>();
var displayLabel = repoName.As<DisplayLabel>();  // re-validates for the target type

From this point forward, I will use .As<T>() as the primary way to create semantic values in the examples that follow.

Type-Safe Paths

Semantic strings show what they're good for once a type hierarchy is built on top of them. The Semantics library defines a path type system with validation built into each layer:

SemanticString<T>
  └─ SemanticPath<T>          [IsPath]
       ├─ SemanticDirectoryPath<T>
       │    ├─ AbsoluteDirectoryPath  [IsAbsolutePath]
       │    └─ RelativeDirectoryPath  [IsRelativePath]
       └─ SemanticFilePath<T>
            ├─ AbsoluteFilePath       [IsAbsolutePath]
            └─ RelativeFilePath       [IsRelativePath]

Each level in the hierarchy adds validation through attributes. [IsPath] ensures no invalid path characters are present. [IsAbsolutePath] ensures the path is fully qualified. This validation runs automatically at creation time. It's impossible to construct an AbsoluteDirectoryPath from a relative path string.

Operator Overloading for Path Composition

The path types overload the / operator for combining paths, and the return type changes based on what is being combined:

var outputDir = @"C:\output".As<AbsoluteDirectoryPath>();
var logFile = @"logs\app.log".As<RelativeFilePath>();
var readme = "README.md".As<FileName>();

// Directory / RelativeFile → AbsoluteFilePath
AbsoluteFilePath fullLogPath = outputDir / logFile;

// Directory / FileName → AbsoluteFilePath
AbsoluteFilePath readmePath = outputDir / readme;

// Directory / RelativeDirectory → AbsoluteDirectoryPath
var subDir = "subdir".As<RelativeDirectoryPath>();
AbsoluteDirectoryPath nested = outputDir / subDir;

// Convert between semantic types with the instance .As<T>()
AbsolutePath genericPath = fullLogPath.As<AbsolutePath>();

Each / overload returns the correct result type. Combining an absolute directory with a relative file produces an absolute file path, not a string that the caller has to hope is correct.

Compare this to Path.Combine:

// Path.Combine returns string — no type information about what kind of path this is
string result = Path.Combine(@"C:\output", @"logs\app.log");
// Is this a file? A directory? Absolute? Relative? The type doesn't say.

Relationship Queries

Because the types carry semantic meaning, the library can provide operations that would be meaningless on raw strings:

var project = @"C:\projects\myapp".As<AbsoluteDirectoryPath>();
var src = @"C:\projects\myapp\src".As<AbsoluteDirectoryPath>();

bool isChild = src.IsChildOf(project);        // true
bool isParent = project.IsParentOf(src);       // true

// Walk up the directory tree
foreach (var ancestor in src.GetAncestors())
{
    Console.WriteLine(ancestor);
}

// Get a relative path between two absolute paths
RelativeDirectoryPath relative = project.GetRelativePathTo(src);

These methods use span-based comparison internally for performance, but the type system ensures IsChildOf can only be called with another AbsoluteDirectoryPath, not with a file path, a relative path, or an arbitrary string.

Validation at Compile Time and Runtime

Semantic types operate at two complementary levels:

  1. Compile-time. The type system prevents mixing incompatible types entirely. An AbsoluteFilePath can't be passed where an AbsoluteDirectoryPath is expected.

  2. Runtime. Validation attributes enforce constraints that can't be expressed in the type system alone, such as valid path characters, fully qualified paths, and similar rules.

The validation relies on an attribute-based system:

[IsPath]
public abstract record SemanticPath<TDerived> : SemanticString<TDerived>
    where TDerived : SemanticPath<TDerived>
{ }

[IsAbsolutePath]
public sealed record AbsoluteDirectoryPath : SemanticDirectoryPath<AbsoluteDirectoryPath>
{ }

The [IsPath] attribute validates that the string contains no invalid path characters and has a reasonable length. The [IsAbsolutePath] attribute validates that the path is fully qualified. These checks execute automatically on every call to .As<T>() or Create. If the string fails to meet the requirements, an exception is thrown immediately rather than producing a silent failure at some later, harder-to-diagnose point.

For cases involving user input or external data, a TryCreate method provides safe fallback behavior:

if (AbsoluteDirectoryPath.TryCreate(userInput, out var path))
{
    // path is guaranteed to be valid
}
else
{
    // handle invalid input
}

The Cost and the Tradeoff

Semantic types are not free. Here is what they cost:

Here is what they give back:

In my experience maintaining a monorepo of 79+ .NET libraries, the upfront cost of defining semantic types pays for itself quickly. The bugs that never happen are the ones nobody has to debug.

Getting Started

Adopting this approach doesn't have to be all-in from day one. Start with the areas where primitive obsession causes the most pain:

  1. Method signatures with multiple string parameters. These are transposition bugs waiting to happen.
  2. File path handling. The distinction between absolute and relative, file and directory, is well worth encoding in types.
  3. Domain identifiers such as user IDs, order numbers, and API keys. Anything where mixing them up would constitute a bug.

Define a semantic type for each concept:

public sealed record UserId : SemanticString<UserId> { }
public sealed record OrderNumber : SemanticString<OrderNumber> { }
public sealed record ApiKey : SemanticString<ApiKey> { }

Three lines, and the compiler is now working on the problem.

References