A .NET library for replacing primitive obsession with strongly-typed, self-validating domain models. Three pillars:
- Semantic Strings — type-safe wrappers like
EmailAddress,UserId,BlogSlugwith attribute-driven validation. - Semantic Paths — polymorphic
IPathhierarchy for files, directories, absolute, relative, and combinations. - Semantic Quantities — a metadata-generated, type-safe quantity system with a unified
IVector0..IVector4model covering 60+ physical dimensions and 200+ generated types. Optional per-storage-type alias packages let you writeMassinstead ofMass<double>.
Targets net8.0–net10.0. Semantic Strings and Paths additionally target netstandard2.0/netstandard2.1; Semantic Quantities is net8.0+ (it requires INumber<T>).
dotnet add package ktsu.Semanticsusing ktsu.Semantics.Strings;
[IsEmailAddress]
public sealed record EmailAddress : SemanticString<EmailAddress> { }
[StartsWith("USER_"), HasNonWhitespaceContent]
public sealed record UserId : SemanticString<UserId> { }
// Direct construction — no generic params needed
var email = EmailAddress.Create("user@example.com");
var userId = UserId.Create("USER_12345");
// Span-based and char[] overloads exist too
var email2 = EmailAddress.Create("user@example.com".AsSpan());
// Safe creation
if (EmailAddress.TryCreate("maybe@invalid", out EmailAddress? safe)) { /* … */ }
// Compile-time safety
public void SendWelcomeEmail(EmailAddress to, UserId userId) { /* … */ }
// SendWelcomeEmail(userId, email); // ❌ compiler error// All must pass (default)
[IsEmailAddress, EndsWith(".com")]
public sealed record DotComEmail : SemanticString<DotComEmail> { }
// Any can pass
[ValidateAny]
[IsEmailAddress, IsUri]
public sealed record ContactMethod : SemanticString<ContactMethod> { }The full attribute catalogue (text, format, casing, first-class .NET types, paths) lives in docs/validation-reference.md.
using ktsu.Semantics.Paths;
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();Conversions: AsAbsolute(), AsAbsolute(baseDirectory), AsRelative(baseDirectory).
Every quantity is a vector. Direction-space dimensionality is part of the type:
| Form | Sign | Examples |
|---|---|---|
IVector0 (magnitude) |
>= 0 |
Speed, Mass, Energy, Distance, Area |
IVector1 (signed 1D) |
signed | Velocity1D, Force1D, Temperature |
IVector2 (2D) |
per-component | Velocity2D, Force2D, Acceleration2D |
IVector3 (3D) |
per-component | Velocity3D, Force3D, Position3D |
IVector4 (4D) |
per-component | reserved (relativistic / spacetime) |
using ktsu.Semantics.Quantities;
// V0 magnitudes — From{Unit} factories use the singular lemma (#49)
var speed = Speed<double>.FromMeterPerSecond(15.0);
var mass = Mass<double>.FromKilogram(10.0);
var distance = Distance<double>.FromMeter(5.0);
// V3 directional — object-initializer syntax (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 };
// Operators flow from dimensions.json
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 workDot = force3d.Dot(disp3d); // Energy
var torque = force3d.Cross(disp3d); // Torque3D
var mag = disp3d.Magnitude(); // Length (always >= 0)
// Construction-time invariants (#50, #51)
// Speed.FromMeterPerSecond(-1.0) // ArgumentException — V0 must be non-negative
// Wavelength<double>.FromMeter(0.0) // ArgumentException — strict-positive overload
// Type safety
// var nope = force3d + speed; // ❌ compiler errorSemantic overloads (e.g. Weight over ForceMagnitude, Diameter ↔ Radius):
var raw = ForceMagnitude<double>.FromNewton(686.0);
var weight = Weight<double>.From(raw); // explicit narrow
ForceMagnitude<double> back = weight; // implicit widen
var radius = Radius<double>.FromMeter(2.0);
var diameter = radius.ToDiameter(); // 4 m via metadata-defined relationshipPhysical constants are exposed in two shapes:
// Domain-grouped, exact PreciseNumber values:
PhysicalConstants.Fundamental.SpeedOfLight; // 299_792_458 m/s as PreciseNumber
PhysicalConstants.Chemistry.GasConstant; // 8.31446... J/(mol·K)
// Generic accessors — materialise into any T : INumber<T>:
var c = PhysicalConstants.Generic.SpeedOfLight<double>();
var R = PhysicalConstants.Generic.GasConstant<decimal>();Every quantity is generic over its storage type (Mass<double>, Speed<float>, …). If a project uses one storage type throughout, reference a satellite package and drop the generic argument entirely:
<PackageReference Include="ktsu.Semantics.Quantities.Double" Version="x.y.z" />using ktsu.Semantics.Quantities;
// No <double> anywhere — the package injects global-using aliases for every quantity.
Mass mass = Mass.FromKilogram(10.0);
Speed speed = Speed.FromMeterPerSecond(15.0);
Mass total = mass + Mass.FromKilogram(2.0); // still a Mass<double>, full identityThe aliases are real Mass<double> (etc.), so they interoperate with the whole API with no conversion. Packages exist for Double, Float, and Decimal — reference exactly one per project to pick the storage type. The alias lists are generated from the quantity catalogue (scripts/Generate-AliasProps.ps1) and validated in CI.
The unified vector model and its rationale: docs/strategy-unified-vector-quantities.md.
A per-domain tour: docs/physics-domains-guide.md.
How the source generator turns dimensions.json into types: docs/physics-generator.md.
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");
}The physics system is metadata-driven. The single source of truth is
Semantics.SourceGenerators/Metadata/dimensions.json (with units.json, magnitudes.json, conversions.json, and domains.json alongside it), and a Roslyn incremental generator emits:
- One record per quantity (Vector0/1/2/3/4 base + semantic overloads).
- A
From{Unit}factory per declared unit, with built-in unit conversion to the SI base unit and aVector0Guardsenforce-non-negative (or strict-positive) check on V0 types. - Cross-dimensional
*,/,Dot,Crossoperators driven byintegrals/derivatives/dotProducts/crossProductsdeclarations. PhysicalConstantswith both domain-groupedPreciseNumberfields and genericT.CreateChecked-backed accessors.
Generator diagnostics catch metadata problems at build time:
- SEM001 — relationship references an unknown dimension name.
- SEM002 — schema-level metadata issue (missing fields, duplicate type names, etc).
- SEM003 — relationship's
formslist references a vector form not declared on a participating dimension. - SEM004 —
availableUnitsreferences a unit not declared inunits.json.
Generated output is committed to Semantics.Quantities/Generated/ so the project compiles without first running the generator.
The string and path systems use the same building blocks: an attribute → strategy → rule → factory pipeline. See docs/architecture.md.
- Complete library guide — start here for a feature tour.
- Architecture (strings/paths/validation)
- Architecture (physics — unified vector model)
- Source-generator workflow
- Physics domains tour
- Validation attributes reference
- Advanced usage patterns
Contributions are welcome — please open an issue first for major changes so we can discuss the direction. The branch's open work items are tracked as GitHub issues.
MIT — see LICENSE.md.