UnitsNet.Modular explores a compile-time composition model for a future UnitsNet architecture. A module author selects only the quantities and units that belong in an assembly, while generated quantity structs keep the strongly typed API and share a small runtime for conversion, parsing, and formatting.
The generator, runtime, and generated types do not reuse the existing UnitsNet runtime or
code-generation model. The small runtime and its clean-slate quantity contracts live together in
the UnitsNet.Modular assembly. A future investigation may identify contracts worth sharing with
UnitsNet, but the proof of concept does not introduce a separate abstraction package before that
boundary has demonstrated value.
The experiment is inspired by the modular-package experiment, the source-generator discussion, and the current Roslyn source-generator model.
The idea is viable if the unit of composition is a consumer-owned module assembly:
- A module declares an interface that selects quantity specs.
- One incremental generator resolves the selection and emits only the selected quantity and unit types into that module.
- The application's domain, persistence, UI, and service projects reference that assembly normally.
The module boundary is important because a generated public type has the identity of the assembly
into which it is generated. If two unrelated assemblies both generate Length, those are distinct
CLR types. An application should therefore generate its quantities once in a shared internal
library, then reference that library everywhere else in the application.
Third parties publish definition packages, not compiled quantity structs. A definition package contains JSON definitions, localizations, relationships, and small public quantity specs. The consumer remains responsible for selecting and compiling those definitions into its module.
Roslyn generators are additive and unordered; a generator cannot consume another generator's output in the same compilation. Consequently, UnitsNet.Modular uses one generator for built-in and custom definitions. Stable public authoring contracts live in the UnitsNet.Modular runtime so definition-package assemblies can reference one identity. Built-in catalog specs and profiles are internal bootstrap source emitted during post-initialization.
The package and assembly retain the UnitsNet.Modular name, while the runtime project's default
namespace is UnitsNet. Namespace ownership follows the role of each type:
- general quantity contracts, metadata, unit-system policy, and quantity math use
UnitsNet; - built-in unit enums use
UnitsNet.Units; - module authoring contracts use
UnitsNet.Modular; - built-in specs and reusable profiles use
UnitsNet.Modular.BuiltInsandUnitsNet.Modular.Profiles; - the immutable registry and generated module singleton use
UnitsNet; and - public implementation types referenced only by emitted code use
UnitsNet.Modular.SourceGenand are hidden from IntelliSense.
This keeps ordinary quantity source close to legacy UnitsNet while making modular composition and
generator plumbing explicit. It does not provide binary compatibility: the legacy and Modular
contracts come from different assemblies. Referencing the UnitsNet and UnitsNet.Modular
packages together in one consumer project is unsupported.
Authoring types use role-specific suffixes: quantity specifications are *Spec, reusable unit
filters are *UnitSet, and reusable selection groups are *Profile. This keeps an input such as
LengthSpec visually distinct from the generated Length quantity and LengthUnit enum. Each
built-in or custom spec identifies its definition through [QuantitySpec]; the generator does not infer
identity from the spec's namespace or type name.
Select every unit for a built-in quantity by inheriting IInclude<TQuantitySpec>:
using UnitsNet.Modular.BuiltIns;
using UnitsNet.Modular;
[UnitsNetModule]
internal interface EngineeringUnits :
IInclude<LengthSpec>,
IInclude<TemperatureSpec>,
IInclude<TemperatureDeltaSpec>,
IInclude<InformationSpec>
{
}Built-in specs generate a source-compatible concrete surface by default: quantities use
UnitsNet and unit enums use UnitsNet.Units:
[UnitsNetModule]
internal interface CompatibilityUnits :
IInclude<LengthSpec>,
IInclude<TemperatureSpec>,
IInclude<TemperatureDeltaSpec>;An explicit module target namespace remains available for side-by-side experiments or applications that want every selected built-in and custom definition under one namespace.
Select units with a regular expression by defining a named unit set:
[UnitSet("regex:.*Meter$")]
internal interface MeterUnitSet;
[UnitsNetModule]
internal interface LeanUnits :
IInclude<LengthSpec, MeterUnitSet>
{
}Patterns prefixed with regex: use case-insensitive, culture-invariant regular expressions with a
timeout. Patterns prefixed with glob: support *, and bare patterns retain glob behavior for
convenience. The generator always includes the base unit so every selected quantity remains
convertible. It reports compile-time diagnostics for invalid expressions and patterns that match no
units.
Quantity profiles compose reusable catalog selections:
using UnitsNet.Modular.Profiles;
[UnitsNetModule]
internal interface ApplicationUnits :
IIncludeProfile<AllQuantitiesProfile>,
IInclude<HowMuchSpec>;AllQuantitiesProfile contains the built-in catalog, while AllSiProfile selects the SI
relationship sample.
Consumers can define profiles from
IInclude<TQuantitySpec> and nest them through IIncludeProfile<TProfile>. Profile selections are
defaults: direct selections on the module override a profile's unit selection for the same quantity.
The recommended application architecture has one module marker in its shared units project. Profiles
and direct includes compose the complete generated surface at that boundary.
Custom quantities use JSON definition files. Consumers should include them with Roslyn's native
AdditionalFiles item:
<ItemGroup>
<AdditionalFiles Include="HowMuch.unitsnet.json"
UnitsNetDefinition="true" />
</ItemGroup>The metadata identifies ordinary filenames such as Length.json that cannot be recognized by
extension alone. Files named *.unitsnet.json are also recognized by convention, but explicit
metadata documents their role and works for either naming scheme.
Package assets under buildTransitive/ retain UnitsNetDefinition and UnitsNetRelation as
convenience aliases. A .targets file maps those custom items to compiler AdditionalFiles, while
a .props file makes their metadata compiler-visible. They work with command-line MSBuild, but
native AdditionalFiles is the portable public syntax because some IDE project models, including
Rider, ignore custom build actions before running their design-time Roslyn host.
The JSON shape follows the existing UnitsNet quantity definitions and adds an optional Namespace
for stable third-party identity; it defaults to UnitsNet, allowing files such as the existing
Length.json to be consumed unchanged. It supports localized abbreviations, prefix expansion, and
FromUnitToBaseFunc/FromBaseToUnitFunc expressions. A minimal quantity spec binds type-safe module
selection to the JSON's logical Namespace.Name ID:
namespace Fictional;
using UnitsNet.Modular;
[QuantitySpec("Fictional.Measurements.HowMuch")]
public interface HowMuchSpec;
[UnitsNetModule]
internal interface FictionalUnits : IInclude<HowMuchSpec>;Definitions are read with System.Text.Json. Its .NET Standard support assemblies are bundled
privately beside the analyzer, while the generated/runtime library has no JSON-library dependency.
Conversion expressions are parsed as C# expressions and restricted to numeric literals, x,
arithmetic operators, parentheses, Math.PI, Math.E, and an allowlist of numeric Math
functions.
The generator emits the validated expressions directly into conversion switches; it does not compile
expressions or use reflection at runtime. A definition package contains public quantity specs and JSON
definitions while the module that selects them owns the generated runtime types. Its package-local
build/*.props file exposes the JSON as compiler AdditionalFiles only to the project that
directly references it.
UnitsNet.Modular: modern quantity contracts, immutable metadata, conversion, parsing, formatting, module discovery, registry behavior, serialization integration, and source-generator packaging.UnitsNet.Modular.Generator: the incremental generator, spec bootstrap source, built-in catalog, diagnostics, and emitters.UnitsNet.Modular.Generator.Tests: generator-driver coverage for diagnostics, stable output, incrementality, and all relationship shapes.UnitsNet.Modular.Tests: generated API and runtime behavior tests.UnitsNet.Modular.Compatibility.Tests: linked-output, full-catalog public API, enum, conversion, parsing, formatting, behavior, and registry comparisons against unchanged UnitsNet. Private fixture projects compile the same compatibility scenario against each implementation.Samples/GettingStartedSample: the two-file quick start with Length, Duration, Speed, and their generated relationship.Samples/QuantitySelectionSample: filtered Length and Information unit sets.Samples/CustomQuantitySample: a fictionalHowMuchquantity in its own namespace.Samples/Profiles/AllSiProfileSample: theAllSiProfilerelationship chain from Length and Duration through Speed, Acceleration, Force, Pressure, Energy, and Power.Samples/ModularPlayground: a broad interactive scenario covering parsing, metadata, serialization, relationships, and an application-specific definition.Samples/SharedUnitsLibrarySample: one generated quantity library shared by domain and app projects, plus a packable definition provider containing specs, JSON, localization, and structured relationships.
Every sample uses the solution platform as its dependency mode. ProjectReferences exercises the
current runtime and generator projects, LocalPackages crosses the repository-local NuGet boundary,
and PublishedPackages consumes the pinned public package. This keeps dependency source separate
from the normal Debug and Release configurations without duplicating scenario projects.
The compatibility test project uses aliased references to compare both implementations' selected public API and unit names without introducing concrete-type ambiguity. It compares against the unchanged UnitsNet project. Whether any contracts can genuinely be shared with UnitsNet remains a separate investigation. The fixtures participate only in the Modular compatibility test graph and do not participate in the existing UnitsNet solution.
The linked-source test fixtures establish source compatibility for factories, properties, unit enums, conversions, parsing, formatting, collection extensions, and operators. Catalog-wide compatibility tests compare all 129 generated quantities and their unit enums with the unchanged UnitsNet source. They exercise every unit's conversion through its base unit, base-unit formatting and parsing, default and strict equality behavior, affine and logarithmic arithmetic, and representative exceptions.
The declared-public-surface gate compares constructors, properties, methods, and operators. Every
accepted difference is categorized in the test: legacy mutable metadata and setup APIs that are not
part of the clean architecture, or a remaining quantity-specific handwritten UnitsNet API.
Duration/TimeSpan interoperability, Area circle construction, relation-backed mechanics and
chemistry helpers, concentration conversions, apparent-power division, and combustion energy are
represented as explicit built-in API augmentations rather than silent exceptions. This also
covers immutable dBV/dBW reference conversions, scalar compound-unit construction, and the
FeetInches, StonePounds, and ReferencePressure companion types. The generator reads
augmentation and companion kinds plus quantity and unit dependencies from immutable embedded data;
dependent APIs are emitted only when all participating quantities and constituent units are
selected. Companion types remain an explicit opt-in with a dedicated emitter; their presence
is never inferred from a quantity or its units. This inventory is a migration tool, not a claim that
every legacy API belongs in the final architecture.
Two handwritten APIs remain intentionally excluded. Length.ParseFeetInches and
TryParseFeetInches depend on a specialized text grammar, while Pressure.FromElevation and
ToElevation implement an empirical atmosphere model rather than unit conversion. The
compatibility suite requires every exclusion to identify an existing UnitsNet member and provide a
non-empty rationale, so stale exclusions fail the test.
UnitsNet.IQuantity<TValue> exposes the stored numeric value and a type-erased enum unit.
UnitsNet.IQuantity<TSelf, TUnit, TValue> adds only the static construction and conversion
primitives needed to implement reusable As() and ToUnit() behavior, while refining the stored
unit to its concrete enum type. The double-based IQuantity<TSelf, TUnit> composite adds the
static canonical Info metadata required from every generated quantity. This follows the familiar
UnitsNet self-type/unit shape without putting metadata on each quantity instance. A generic library
can therefore consume, create, convert, or inspect generated implementations through one Modular
contract.
The capability hierarchy adapts UnitsNet's proven modern generic design without carrying over
UnitKey, mutable quantity metadata, setup registries, or obsolete compatibility members:
ILinearQuantity<TSelf, TUnit>advertises conventional arithmetic and additive zero;IAffineQuantity<TSelf, TUnit, TOffset>identifies offset conversions and expresses differences through a linear offset quantity without claiming conventional same-quantity arithmetic;ILogarithmicQuantity<TSelf, TUnit>identifies logarithmic arithmetic and scaling without claiming conventional generic-math semantics.
QuantityMath.Sum and QuantityMath.Average use those contracts for reusable mixed-unit linear
algorithms over generated quantities. AffineQuantityMath.Average averages affine values in an
explicit target unit. LogarithmicQuantityMath supplies logarithmic sum and mean semantics.
Generated concrete extension methods expose these algorithms as Sum, Average,
ArithmeticMean, GeometricMean, Abs, and tolerance-aware Equals, so normal application code
does not need generic call syntax. A separate integration branch validates these algorithms with
UnitsNet v6. The capability layer remains double-based while numeric storage abstraction is
evaluated separately.
QuantityId belongs to the canonical Info object rather than each value instance. Base-unit
conversion is derived behavior and is intentionally not stored on each
instance. Generated relationships and equality use internal conversion helpers; reusable public
conversion behavior belongs in the self-typed quantity contract and is backed by immutable
definition metadata. There is no global conversion registry: compile-time specs and definition
metadata generate the selected converters directly into the consumer-owned assembly. Internal base
values are sufficient for relationships because all participating quantities are generated into
that assembly; independently compiled modules cannot acquire cross-module operators.
Semantic IDs are namespace-qualified (Namespace.Name) and definition-package authors should use
a namespace they own. This makes IDs stable and globally meaningful at registry and serialization
boundaries without adding vendor state to each quantity value.
Each module does have an immutable generated discovery registry. It describes only that
module's selected quantities and supports lookup by semantic ID, quantity name, or generated CLR
type. Each generated quantity exposes its canonical strongly typed
QuantityInfo<TQuantity, TUnit> through static Info; quantity instances do not duplicate or carry
metadata. Info owns the stable ID, name, base-unit metadata, generated unit metadata, and base
dimensions. Info.BaseUnit and every item in Info.Units are UnitInfo<TUnit> objects, whose
Value property returns the actual enum value. This avoids parallel raw-enum and metadata
collections. Common value behavior such as Value, Unit, Zero, From, Convert, As,
ToUnit, parsing, and formatting remains on the quantity type.
The same Info object implements the type-erased IQuantityDescriptor contract used by the
registry, so there is no parallel quantity-metadata graph. The descriptor exists for heterogeneous
runtime workflows where the quantity type is not statically known; it exposes units,
abbreviations, base dimensions, construction, conversion, parsing, formatting data, and stored
value/unit access. Frozen dictionaries make lookup immutable after module initialization. This
registry is not a source of conversion policy and is not a replacement for the old mutable
UnitsNetSetup model.
BaseDimensions and the base unit are deliberately available through Info only: both are central
to generated conversion and discovery but uncommon in ordinary strongly typed application code.
BaseUnitInfo and UnitInfos are hidden source-compatibility aliases on QuantityInfo; new code
uses BaseUnit and Units. Public contracts needed only so generated code can call the runtime,
such as UnitsNet.Modular.SourceGen.IQuantityMetadata<TUnit> and
UnitsNet.Modular.SourceGen.QuantityOperations, live in the clearly separated SourceGen
namespace, are hidden from IntelliSense, and are documented as generator infrastructure. Consumer
code should use the generated quantity API instead.
The same descriptors back a generated System.Text.Json converter factory. Its quantity dispatch is
emitted as direct type checks and generic converter construction, with no runtime
MakeGenericType, assembly scan, or mutable registration. It is therefore suitable for trimming
and Native AOT. Serialized data still forms an application-owned compatibility boundary; the
registry does not make independently generated CLR types binary compatible.
UnitsNet.Modular deliberately exposes its clean-slate contracts under the familiar UnitsNet
namespace. This improves source compatibility but does not reproduce legacy interface identity:
the contracts come from a different assembly and have deliberately slimmer shapes. Exact binary
identity would require a canonical shared assembly coordinated with UnitsNet itself.
The legacy compatibility review concluded that common read-only dynamic workflows belong on the
immutable module registry, with a thin owner-scoped Quantity facade for familiar static call
shapes. The facade returns UnitsNet.IQuantity<double> and delegates to its exposed
Quantity.Registry; it does not introduce a second catalog. Construction, type-directed parsing,
metadata discovery, and non-throwing input paths are tested against their UnitsNet counterparts.
The review rejected mutable legacy behavior, not static convenience. UnitsNetSetup, mutable
conversion registration, abbreviation-cache mutation, and mutable global defaults express
process-wide runtime policy that conflicts with consumer-owned compile-time definitions.
The Modular runtime instead provides immutable, owner-neutral UnitsNet.UnitSystem and
UnitsNet.BaseUnits values that are passed explicitly to generated constructors, From, As,
and ToUnit, or to the facade,
descriptors, and registry. Resolution is restricted to selected units. Generated constituent
metadata and SI selection are checked catalog-wide against UnitsNet, including exponent-aware
prefix metadata and legacy first-match ordering. UnitKey is unnecessary in strongly typed code;
dynamic and serialized boundaries use semantic quantity IDs plus invariant unit names. The
complete workflow matrix is in MIGRATION.md.
The experiment does not provide binary compatibility between concrete quantity structs. CLR type
identity includes the defining assembly, so UnitsNet.Length from UnitsNet.dll and a type with
the same full name generated into an application assembly are not assignment-compatible.
Projects inside one application share its consumer-owned module. Independent applications exchange
shared contracts or explicit serialized data instead of assuming their generated structs have the
same identity.
The runtime, contracts, metadata, and bundled generator ship as one signed UnitsNet.Modular
assembly/package boundary. This keeps installation and local development simple while the
architecture is still being evaluated. A separate contracts package should be extracted only if a
future legacy/modular integration finds a genuinely shared interface set or another independently
versioned consumer of the runtime contracts.
Samples built with the LocalPackages platform import one repository-only MSBuild target that
incrementally packs changed UnitsNet.Modular or generator sources before restore, then refreshes
their floating 6.0.0-local.dev.* dependencies before compilation. The shared-units library
registers its fictional definition provider as an additional package, so the automation packs the
runtime first and the definition package second with the same unique version. Restore is restricted
to the shared Artifacts/Nugets development feed and cannot fall back to a published package.
The dependency can also be invoked explicitly:
dotnet msbuild `
UnitsNet.Modular/Samples/CustomQuantitySample/CustomQuantitySample.csproj `
-p:Platform=LocalPackages `
-t:UpdateLocalUnitsNetModularPackagesThis repository-only automation defaults on whenever LocalPackages is selected. Set
UnitsNetModularSampleUpdateLocalPackagesOnBuild=false to use packages already present in the local
feed. The older singular property and target names remain aliases for existing local commands.
RepositoryLocalNuGetFeed in the root Directory.Build.props gives every repository project the
shared Artifacts/Nugets path. The repository-level NuGet.Config exposes it to solution-wide IDE
package tooling, and generated packages remain gitignored. The UnitsNet.Modular package project supports
a plain dotnet pack UnitsNet.Modular/UnitsNet.Modular/UnitsNet.Modular.csproj, which creates a unique
6.0.0-local.dev.* package in that feed for the real-consumer samples. The dedicated dev
identifier prevents another local prerelease label from shadowing the floating dependency. Local
development versions are prereleases, so enable prerelease packages and refresh the feed in the IDE
after packing. Pass
-p:UnitsNetModularPackForPublish=true to create the MinVer-derived publish version instead; CI sets
this explicitly.
Run the minimal package-facing sample from the repository root:
pwsh UnitsNet.Modular/Samples/GettingStartedSample/run.ps1The script selects Debug | LocalPackages, provides an isolated package cache, and lets the sample
build dependency pack and restore before executing the consumer.
The UnitsNet.Modular package has one MinVer release stream with the tag prefix
UnitsNet.Modular/, a minimum version of 6.0, and alpha.0 as the default prerelease identifiers.
Existing UnitsNet/*, JsonNet/*, and unprefixed tags are ignored. A release tag such as
UnitsNet.Modular/6.0.0-alpha.1 or UnitsNet.Modular/6.0.0 becomes the exact package version.
Untagged builds receive a MinVer-generated alpha version with commit height.
UnitsNet.Modular.Generator remains an internal, non-packable project because the analyzer and its
private dependencies are bundled into the Modular package.
UnitsNet and UnitsNet.Modular share major version 6 to communicate the catalog generation they belong to. UnitsNet retains its existing explicitly controlled version, while UnitsNet.Modular advances independently. Third-party definition packages have independent versions; the fictional sample remains at 1.x when packed directly.
Create an annotated release tag on a green master commit with the Modular bump script, then push
the tag:
pwsh Build/bump-version-UnitsNet.Modular.ps1 -Bump suffix
git push origin UnitsNet.Modular/6.0.0-alpha.2Use minor or patch instead of suffix to bump that numeric component and remove the prerelease
suffix, matching the existing UnitsNet release scripts. After a stable release, suffix starts the
next patch prerelease at alpha.1, matching MinVer's post-release version range. Pass -WhatIf to
preview the tag without creating it.
This tag versions and publishes the UnitsNet.Modular package.
The local package automation passes a timestamped MinVerVersionOverride so repeated packages
containing uncommitted changes remain unique. The package includes complete NuGet metadata,
including its README, icon, XML API documentation, repository commit metadata, and portable PDBs
in an .snupkg.
GitHub Actions enables ContinuousIntegrationBuild, producing deterministic CI packages with
stable source paths
and Source Link metadata for the matching commit. Local development packages retain developer source
paths and may therefore be reported as non-deterministic by NuGet Package Explorer; they are not
publishing artifacts.
The separate UnitsNet.Modular CI workflow uses full Git history, builds and tests UnitsNet.Modular.slnx,
runs the minimal NuGet consumer with an isolated package cache, packs the combined package with its
MinVer version, and uploads it as a workflow artifact. Upstream pushes to master stop there.
UnitsNet.Modular/* tag pushes additionally publish the exact tagged version to NuGet.org, and a
manual run from such a tag can opt into publishing for recovery. Before uploading or publishing, CI
verifies that the package filename contains the exact version declared by the tag. NuGet.org trusted
publishing must authorize the angularsen/UnitsNet repository, the unitsnet-modular-ci.yml
workflow, and the Publish environment.
UnitsNet.Modular/Directory.Packages.props and UnitsNet.Modular/Directory.Build.targets support development
with an analyzer ProjectReference; they are not copied into the shipped package. Project
references do not automatically expose private analyzer dependencies. The local target therefore
points Roslyn at the restored System.Text.Json support assemblies.
Packaged consumers do not configure any of this. The support assemblies are private files beside
UnitsNet.Modular.Generator.dll under analyzers/dotnet/cs, and the package declares no runtime
dependency on them. _UnitsNetModularAnalyzerDependencyDirectory is evaluated only while packing to
locate those
files; it is not a consumer-facing MSBuild property or API.
The UnitsNet.Modular runtime supplies assets for .NET 8, 9, and 10. The standalone prototype does
not modify any UnitsNet target or make UnitsNet reference the Modular contracts. Possible modern
UnitsNet v6 adoption remains a separate integration investigation.
The generator remains a netstandard2.0 analyzer solely so current compiler and IDE hosts can load
it regardless of the consumer target. That analyzer target is an implementation constraint, not
runtime support for generated quantity modules.
On all supported runtime targets, generated quantities implement IParsable<TSelf> and applicable
Modular capability and generic-math interfaces. Linear quantities support conventional arithmetic and
shared aggregation; affine quantities add or subtract linear offsets and produce an offset when
subtracted from one another; logarithmic quantities keep their explicit logarithmic behavior. All
generated quantities support generic comparison.
The Modular runtime project enables the .NET AOT compatibility analyzers. CI publishes and runs the lean sample with Native AOT on Linux. The generator remains a managed build-time analyzer and explicitly does not inherit an application's publish, runtime identifier, trimming, or self-contained settings. The lean generated consumer targets .NET 8, 9, and 10 so the normal build also compiles emitted source against every supported runtime target.
Further modern-target opportunities include allocation-free
ISpanParsable<TSelf>/ISpanFormattable paths, UTF-8 parsing and formatting, and optionally
abstracting the numeric storage type through generic math. Those require deliberate API and
performance design beyond this POC.
For each selected definition, the generator emits:
- a unit enum containing only selected units;
- an immutable strongly typed quantity struct;
- typed
FromXxx()factories, a genericFrom(value, unit)factory, and.Xxxconversion properties; - static canonical
Infometadata through the Modular self-typed contract, including semantic identity and base-unit information; As(),ToUnit(),Parse(),TryParse(), andToString();- default values normalized to zero in the base unit, matching UnitsNet;
- arithmetic selected by the definition's linear, affine, or logarithmic semantics;
- affine arithmetic generated with a selected linear offset companion, with
UNM015reporting a missing companion before emission; - modern .NET generic parsing, comparison, and capability contracts;
- localized unit metadata that delegates shared behavior to the runtime;
- direct, validated conversion switches for affine and nonlinear conversions;
- a module-wide immutable discovery registry and AOT-safe System.Text.Json converter factory.
When all operands and results are selected, the generator emits relationships such as:
Length * Length -> AreaLength / Duration -> SpeedSpeed / Duration -> AccelerationMass * Acceleration -> ForceForce / Area -> PressureForce * Speed -> PowerPower * Duration -> EnergyEnergy / Duration -> Power
Relationship operators convert through the anchor units named by each equation. Selecting the participating quantities controls whether an operator exists; anchor units do not need to be exposed in the generated public unit enums. The generator uses the full immutable definitions to inline the necessary private conversions and constructs the result in its selected base unit.
The built-in inventory comes from Common/UnitRelations.json rather than hardcoded quantity names.
The relation pipeline resolves endpoints globally by semantic quantity ID, generates both operand
orders for commutative multiplication, infers division, and honors NoInferredDivision. Generated
types may remain in different CLR namespaces because emitted signatures use fully qualified names.
Third-party packages and applications can add structured semantic relationships through
AdditionalFiles items marked with UnitsNetRelation="true":
[
{
"result": { "quantity": "Fictional.HowMuchDistance", "unit": "SomeMeter" },
"left": { "quantity": "Fictional.HowMuch", "unit": "Some" },
"operator": "*",
"right": { "quantity": "UnitsNet.Length", "unit": "Meter" }
}
]The existing UnitsNet string equations remain supported and are normalized to the same semantic model. Structured relations are preferred for third-party packages because semantic IDs remain unambiguous across namespaces.
The catalog model is designed for all UnitsNet quantity and unit definitions. Definitions come from the UnitsNet JSON catalog and cover linear, affine, and logarithmic behavior; SI, non-SI, decimal-prefix, and binary-prefix units; localized abbreviations; and cross-quantity relationships.
AllQuantitiesProfile selects all 129 built-in definitions directly from the repository catalog,
without a second handwritten name inventory. AllSiProfile exercises the complete SI
relationship chain in a focused sample, while the representative sample provides a faster varied
selection for day-to-day generator iteration. JSON-backed third-party definitions participate in
the same
selection, profile, conversion, localization, and relationship model as built-ins.
The full-catalog generator gate emits 132 source files (129 quantities plus module sources) and about 3.16 million source characters in roughly 0.63 seconds on the development machine used for this POC. The budgets in the test are intentionally generous to catch accidental order-of-magnitude regressions rather than benchmark noise. Running the identical input twice also verifies stable generated text, while the incremental generator test verifies cached output for an unchanged full catalog request.
- This is a design probe for a future architecture, not yet a committed replacement for UnitsNet v6.
- Quantity values use
doubleonly. - Generic numeric storage, explicit culture-selection policy, and rich parse ambiguity handling are deferred.
- System.Text.Json has an immutable-registry proof of concept; long-term serialized-contract versioning and compatibility adapters remain application concerns.
- Regex/glob patterns filter expanded unit names, not abbreviations.
- Prefix expansion uses a common SI/binary prefix table; it does not yet reproduce every culture-specific prefix convention from UnitsNet v6.
- Definition packages contain specs, not quantity structs. Independently generated application modules intentionally have distinct CLR type identities.
- The supported application pattern uses one module marker in one consumer-owned units project;
UNM014reports additional module markers before they can emit colliding types. - Canonical precompiled third-party modules and operators between independently compiled modules are outside this prototype's scope.
- Legacy mutable setup, runtime registration, global defaults, and exact legacy interface identity remain deliberately unsupported.
- Referencing legacy
UnitsNet.dllin a project that declares a Modular module is unsupported and reported asUNM016; migrate the generation boundary to one implementation at a time.
- Omitting a quantity prevents its type and metadata from entering the consumer assembly.
- Unit patterns reduce the generated enum and typed API.
- Built-in and custom quantities share the same runtime behavior and strongly typed surface.
- Cross-quantity APIs can be conditional rather than forcing a monolithic dependency graph.
- No runtime reflection or assembly scanning is required.