ktsu.Semantics: Type-Safe Strings, Paths, and Quantities in .NET
I've written before about eliminating primitive obsession with semantic types. That's the bug class where everything in an application is a string, a double, or an int, and the compiler can do nothing to help tell a user ID apart from an email address apart from a ZIP code. That post made the case in the abstract. This one is about the library I actually use to do the thing.
ktsu.Semantics is the .NET library that grew out of a few earlier ktsu libraries (StrongPaths, SemanticQuantity, others) consolidated into one coherent system. It's the library I reach for first on any new .NET project where the domain has more than three or four conceptually distinct string and numeric values. The pitch is simple. Make domain types impossible to misuse, with zero runtime cost over their underlying primitives.
This is what the library does, how it's structured, and where it pays off in a codebase.
The Core Idea
A semantic type wraps a primitive with a name and a contract. Where the primitive says "this is a string," the semantic type says "this is an email address, and these are the rules that make a value valid as one." Where the primitive lets any string slip through any function parameter that takes a string, the semantic type makes the compiler enforce that only an email address can be passed to a parameter that expects an email address.
The simplest version looks like this:
using ktsu.Semantics;
[IsEmail]
public sealed record EmailAddress : SemanticString<EmailAddress> { }
[HasLength(8, 50), IsNotEmpty]
public sealed record UserId : SemanticString<UserId> { }
That's the entire type definition. The attributes specify the validation. The base class supplies the conversion, comparison, and storage machinery. The result is a type that can't exist with an invalid value, and that can't be confused with another semantic string type at compile time.
The creation patterns matter. The library supports several, depending on how strict the calling code needs to be.
// Throws if invalid — useful when you know the value is good
var email = EmailAddress.Create("user@example.com");
// From span, for performance-sensitive paths
var userId = UserId.Create("USER_12345".AsSpan());
// Explicit cast — concise, throws if invalid
var email = (EmailAddress)"user@example.com";
// Safe creation — no exceptions
if (EmailAddress.TryCreate("maybe@invalid", out EmailAddress? safeEmail))
{
// safeEmail is valid here
}
And the value of having the types defined this way shows up at the call site.
public void SendWelcomeEmail(EmailAddress to, UserId userId) { ... }
// This won't compile — type safety in action
SendWelcomeEmail(userId, email); // ❌ Compiler error
That compiler error is the bug that didn't make it into production. Multiply by every parameter, every function, every codebase, and the cumulative effect is meaningful.
What's in the Library
The Semantics package is broader than strings now. There are four pieces.
Semantics.Strings provides the foundational semantic-string types with the validation attribute system. Around fifty built-in validation attributes cover the common cases: format validators (email, URL, UUID, hex), length and range constraints, character class checks, regex, and combinators. A project can add its own attributes for project-specific validation.
Semantics.Paths provides specialized path types built on top of the semantic-string foundation. FilePath, DirectoryPath, AbsolutePath, RelativePath, with polymorphic interfaces so functions that accept "any path" can do that without being string-typed. The library handles cross-platform path normalization, query operations (does this exist, is this a child of that), and the kinds of file-system operations that are otherwise awkwardly bolted onto raw strings.
Semantics.Quantities is the physics quantity system. Around eighty quantities across eight scientific domains (mechanics, thermodynamics, electromagnetism, etc.), with dimensional analysis and centralized physical constants. The point is that a Temperature is not a double, a Velocity is not a double, and Distance / Time = Velocity is a thing the type system enforces. This is useful for any domain that does real physical calculation, less useful for typical CRUD-shaped applications.
Semantics.SourceGenerators produces some of the boilerplate (mostly the conversion operators and the validation pipeline) at compile time rather than at runtime. The reason for them is performance. Semantic types are designed to have zero or near-zero runtime cost over their underlying primitives, and the source generators are how that's achieved without sacrificing the API ergonomics.
The Alternatives
There are other approaches to type-safe domain modeling in .NET. The two main alternatives are records and value object frameworks.
Record types with primary constructors. public record EmailAddress(string Value); is a one-line approach that gives a distinct type and structural equality. What it doesn't give: validation at construction, conversion operators, span-based creation paths, integration with serialization/EF/ASP.NET model binding, or compile-time guarantees against the primitive sneaking back through. Records are fine for very small projects, and the library is fine for projects that have outgrown them.
Value object frameworks (Vogen, StronglyTypedId, Dunet). All capable, all somewhat different in their API ergonomics. ktsu.Semantics is opinionated toward a specific style (attribute-driven validation, polymorphic interfaces for related types, integrated path and quantity support) that I find more useful for the kind of code I write. The real version is that I built this library because the alternatives weren't quite shaped the way I wanted them. If one of them is shaped the right way for a given project, use that one. The cost of being wrong about which library is "best" here is small.
The specific things ktsu.Semantics does that I haven't found combined elsewhere:
- Path types as first-class citizens, with the polymorphic interfaces that make "any path" a real concept.
- A built-in physics quantity system that integrates with the semantic-string approach using the same idioms.
- Span-based creation paths for performance-sensitive code.
- Source-generator-backed conversion machinery that keeps the runtime cost low.
If any of those is in the hot path, the library is worth a look. If none of them is, a smaller framework or hand-rolled records may be the better fit.
Three Places It Pays Off
Three places I've consistently found the library pays for itself.
Configuration and external input. Any time a string crosses a system boundary (file paths from disk, identifiers from a database, fields from user input) wrapping it in a semantic type at the boundary turns "did anyone validate this" from a code-review question into a compiler-enforced property. The code that consumes it receives the validated type, not the raw string.
Cross-system identifiers. A codebase that handles user IDs, organization IDs, session IDs, and request IDs as four distinct types instead of four strings eliminates the entire class of bug where the wrong identifier gets passed to the wrong function. The bug feels theoretical until a team has shipped one, and then it feels obvious.
Domain calculations. Code that computes distances, velocities, durations, or temperatures (anything where a double could plausibly mean two different physical quantities) gets the protection of the quantity types, which make that unit confusion impossible. The classic Mars-orbiter unit-mismatch bug is the dramatic version. Less dramatic versions show up in any codebase that mixes seconds and milliseconds in the same function.
The Tradeoffs
Three honest tradeoffs.
The upfront type definition is more verbose than string. Yes. The savings are at the call sites, where the types prevent mistakes. The cost is paid once per type and recouped across thousands of uses.
The first project spends a small amount of time fighting JSON serialization and EF mappings. The library has integrations for common frameworks, and they work, but the first project takes a couple of hours of "here is how the library wants to be wired in." After that, the approach is reusable.
Debugging gets slightly noisier. A semantic type wrapping a string shows up in the debugger as EmailAddress { Value = "user@example.com" } instead of just "user@example.com". Most IDEs handle this fine, but it's a small ergonomic cost.
Installation and Starting Point
dotnet add package ktsu.Semantics
What I'd recommend on a new project is to identify the three or four most-confused string types in the domain and convert them first. User IDs and account IDs that look identical to each other. File paths and URLs that get mixed up. Display names and slugs that come in through different inputs but flow through the same code paths. Convert those, ship the change, observe what happens.
What usually happens is one or two compile errors that turn out to have been latent bugs, and a substantial reduction in the kind of code-review comment that says "are we sure this is the right kind of string here." That signal is the library's real value. The verbose type definitions are the cost, the eliminated category of bug is the return.