ktsu.Semantics is a .NET library for replacing primitive obsession with strongly-typed, self-validating domain models. It has three pillars:
- Semantic Strings — type-safe string wrappers with attribute-driven validation.
- Semantic Paths — polymorphic file system path types with rich operations.
- Semantic Quantities — a metadata-generated, type-safe physics system built on a unified vector model.
All three share a runtime philosophy: validate at construction time, fail fast with ArgumentException, and never let an invalid value into the type.
| Topic | Doc |
|---|---|
| Architecture (strings/paths/validation) | architecture.md |
| Architecture (semantic quantities) | strategy-unified-vector-quantities.md |
| Source-generator workflow | physics-generator.md |
| Validation attribute reference | validation-reference.md |
| Advanced patterns | advanced-usage.md |
| Physics quick reference by dimension | physics-domains-guide.md |
Define a strongly-typed string by deriving from SemanticString<TSelf> and decorating with validation attributes:
[IsEmailAddress]
public sealed record EmailAddress : SemanticString<EmailAddress> { }
[StartsWith("USER_"), HasNonWhitespaceContent]
public sealed record UserId : SemanticString<UserId> { }Construction goes through one of:
// Direct, type-inferred
var email = EmailAddress.Create("user@example.com");
var userId = UserId.Create("USER_12345");
// From char span / array
var email2 = EmailAddress.Create("user@example.com".AsSpan());
// Explicit cast
var email3 = (EmailAddress)"user@example.com";
// Safe creation
if (EmailAddress.TryCreate("maybe@invalid", out EmailAddress? safeEmail)) { /* … */ }Compile-time safety prevents the classic mix-up:
public void SendWelcomeEmail(EmailAddress to, UserId userId) { … }
// SendWelcomeEmail(userId, email); // ❌ won't compileValidation runs through the strategy/rule pipeline — see architecture.md and validation-reference.md.
Use SemanticStringFactory<T> when you want to inject construction:
services.AddTransient<ISemanticStringFactory<EmailAddress>, SemanticStringFactory<EmailAddress>>();
public class UserService(ISemanticStringFactory<EmailAddress> emails)
{
public Task<User> CreateUserAsync(string raw) =>
emails.TryCreate(raw, out var email)
? Task.FromResult(new User(email))
: throw new ArgumentException("invalid email");
}Paths are a separate hierarchy on top of SemanticString<TSelf>. Everything is a record so equality and immutability come for free.
IPath
├── IAbsolutePath ├── IFilePath
├── IRelativePath └── IDirectoryPath
├── IAbsoluteFilePath : IFilePath, IAbsolutePath
├── IRelativeFilePath : IFilePath, IRelativePath
├── IAbsoluteDirectoryPath : IDirectoryPath, IAbsolutePath
└── IRelativeDirectoryPath : IDirectoryPath, IRelativePath
IFileName, IFileExtension // separate hierarchies for non-path components
var configFile = AbsoluteFilePath.Create(@"C:\app\config.json");
configFile.FileName; // config.json
configFile.FileExtension; // .json
configFile.DirectoryPath; // C:\app
configFile.Exists; // bool
// Polymorphic collections
List<IPath> all = [
AbsoluteFilePath.Create(@"C:\data.txt"),
RelativeDirectoryPath.Create(@"logs\app"),
FilePath.Create(@"document.pdf")
];
var files = all.OfType<IFilePath>().ToList();
var absolutes = all.OfType<IAbsolutePath>().ToList();AsAbsolute()— using current working directory.AsAbsolute(baseDirectory)— using a specific base.AsRelative(baseDirectory)— relative against a base.
The physics system is metadata-driven: the source of truth is Semantics.SourceGenerators/Metadata/dimensions.json, and the Roslyn generator emits one record per quantity into Semantics.Quantities/Generated/.
Every quantity is a vector. Direction-space dimensionality is part of the type:
| Form | Sign | Examples |
|---|---|---|
IVector0<TSelf, T> (magnitude) |
>= 0 |
Speed, Mass, Energy, Distance, Area |
IVector1<TSelf, T> (signed 1D) |
signed | Velocity1D, Force1D, Temperature, ElectricCharge |
IVector2<TSelf, T> (2D) |
per-component | Velocity2D, Force2D, Acceleration2D |
IVector3<TSelf, T> (3D) |
per-component | Velocity3D, Force3D, Position3D |
IVector4<TSelf, T> |
per-component | reserved (relativistic / spacetime) |
IVectorN.Magnitude() (for N >= 1) returns the corresponding IVector0.
The model and its rationale live in strategy-unified-vector-quantities.md. Rules of thumb:
- A
Vector0is always non-negative.Speed.Create(-1)throws. V0 - V0returns the sameV0ofT.Abs(a - b)(signed subtraction must use V1 explicitly).- A semantic overload (e.g.
WeightoverForceMagnitude) implicitly widens to its base; narrowing is explicit. - All values are stored in SI base units.
// Vector0 — magnitudes (non-negative)
var speed = Speed<double>.FromMeterPerSecond(15.0);
var mass = Mass<double>.FromKilogram(10.0);
var distance = Distance<double>.FromMeter(5.0);
var energy = Energy<double>.FromJoule(1_000.0);
// Vector1 — signed scalar
var v1 = Velocity1D<double>.FromMeterPerSecond(-3.5);
var temp = Temperature<double>.FromKelvin(300.0);
// Vector3 — directional (object-initializer with X/Y/Z components)
var force3d = new Force3D<double> { X = 0.0, Y = 0.0, Z = -9.8 };
var disp3d = new Displacement3D<double> { X = 3.0, Y = 4.0, Z = 0.0 };Cross-dimensional operators are declared in dimensions.json and emitted automatically:
// V0 × V0 (magnitudes)
var force = mass * AccelerationMagnitude<double>.FromMeterPerSecondSquared(9.8); // Mass × Accel = Force
var work = ForceMagnitude<double>.FromNewton(10.0) * distance; // F·d = Energy
var power = work / Duration<double>.FromSecond(2.0); // W/t = Power
// Vector ops
var workScalar = force3d.Dot(disp3d); // Energy (V0)
var torque = force3d.Cross(disp3d); // Torque3D (V3)
var magnitude = disp3d.Magnitude(); // Length (V0, always >= 0)
// Type safety
// var nope = force + temp; // ❌ compiler errorSeveral dimensions declare narrower-named overloads with implicit widening:
var w = Weight<double>.From(force); // Weight is a ForceMagnitude
var fm = ForceMagnitude<double>.From(w); // implicit widening also OK
var d = Distance<double>.FromMeter(10.0);
var rad = Radius<double>.From(d);
var dia = rad.ToDiameter(); // 20m via metadata-defined relationshipOverload preservation: Weight + Weight => Weight, but Weight + Drag => ForceMagnitude (narrowest-shared base).
Centralised, generated, and generic over storage type:
var c = PhysicalConstants.Generic.SpeedOfLight<double>(); // 299_792_458 m/s
var h = PhysicalConstants.Generic.PlanckConstant<double>();
var R = PhysicalConstants.Generic.GasConstant<decimal>();
var ftM = PhysicalConstants.Conversion.FeetToMeters<double>(); // 0.3048Backing storage is PreciseNumber; the accessor converts via T.CreateChecked per call.
Edit dimensions.json and rebuild — see physics-generator.md for the full schema and an end-to-end walk-through.
Validation is attribute-driven and pipes through a strategy + rule architecture:
- Attribute layer — declarative validation on a type (
[IsEmailAddress],[HasNonWhitespaceContent]). - Strategy layer —
ValidateAllStrategy(default),ValidateAnyStrategy, or a customIValidationStrategy. - Rule layer —
IValidationRuleimplementations selected per attribute. - Factory layer —
ValidationStrategyFactoryresolves the right strategy.
// Default: all must pass
[HasNonWhitespaceContent, IsEmailAddress, EndsWith(".com")]
public sealed record BusinessEmail : SemanticString<BusinessEmail> { }
// Any can pass
[ValidateAny]
[IsEmailAddress, IsUri]
public sealed record ContactMethod : SemanticString<ContactMethod> { }The full attribute list is in validation-reference.md. The runtime architecture (interfaces, strategies, contracts) is in architecture.md.
The library is tuned for throughput-sensitive scenarios:
PooledStringBuilder— pooled, disposableStringBuilderfor hot paths.InternedPathStrings— intern frequently-used path literals.SpanPathUtilities— span-based path manipulation with no allocations.- Validation runs once at construction and caches the verdict on the immutable record.
[HttpPost]
public IActionResult CreateUser([FromBody] CreateUserRequest req)
{
if (!EmailAddress.TryCreate(req.Email, out var email))
return BadRequest("Invalid email");
return Ok(new User(email));
}modelBuilder.Entity<User>()
.Property(u => u.Email)
.HasConversion(
email => email.ToString(),
value => EmailAddress.Create(value));services.AddTransient<ISemanticStringFactory<EmailAddress>, SemanticStringFactory<EmailAddress>>();strategy-unified-vector-quantities.mdfor the physics architecture rationale and the full type hierarchy.physics-generator.mdfor the metadata schema and how to add new dimensions.architecture.mdfor SOLID/DRY patterns inside strings, paths, and validation.validation-reference.mdfor the complete attribute catalogue.advanced-usage.mdfor custom validation, contract validation, and DI patterns.