- Status: draft (horizon); pillar 9 (typestate / protocols) has a first slice built —
the C# state-protocol profile over OwnIR v1 (
move,borrow_mut), see the extractor's README - Depends on:
spec/OwnCore.md(the ownership/affine core and its fact vocabulary),spec/Lifetimes.md; relates to P-005 (IDisposabletypestate — the first concrete protocol), P-006 (DI lifetimes — a region contract, not a protocol), P-007 (ArrayPool/Span borrow-view — the pooled-buffer instance), P-008 (effects — theuse !Dbhalf of a signature), and P-017 (multi-stack frontends — whereOwn.Typesfacts travel beyond C#). Seedocs/ROADMAP.mdfor where this sits in the strategy.
Own.Types is not trying to make C# pretty. Own.Types makes domain lies mechanically harder to write.
The guiding heuristic: types aren't only about the shape of data
(string/int/User). They can encode validity, access rights, state,
dimension, order of operations, effects, protocol, ownership, even proofs. If a
bug arises because "this value was in the wrong state / not validated / used in
the wrong place / called in the wrong order", it is a candidate for a smarter
type — one that makes the bad program unrepresentable instead of merely
unit-tested.
C# gives you the shape dimension and almost nothing else. The two dimensions it
lacks are exactly the interesting ones: what a value MEANS (ProductId is
not DeclarationId, even though both are string) and what STATE it is in
(a Report that is Draft cannot be exported). The usual C# coping mechanism —
make everything string / int / Guid / Dictionary<string, object> — is not
flexibility. It is homeless JSON pretending to be architecture.
Own.Types adds those dimensions as an external static-contract layer over
existing C#: an analyzer / source generator / .own spec that checks the
discipline, without rewriting the code into a new language. This is the move that
turns Own.NET from "a borrow checker for C#" into "an external static contract
layer for C#/.NET that adds ownership, typestate, effects, capabilities, and
domain types" — while the DSL stays a spec/model/contract language and pointedly
refuses to become a second C# people write business logic in.
Own.Types
├─ Semantic primitives — what a value MEANS
│ ├─ newtype / branded types
│ ├─ constrained (refinement) types
│ ├─ units / quantities
│ └─ strongly typed IDs
│
├─ Algebraic domain modeling — what a value CAN BE
│ ├─ discriminated unions
│ ├─ exhaustive matching
│ ├─ Option
│ └─ Result / error unions
│
├─ State/lifetime discipline — what STATE a value is in
│ ├─ typestate
│ ├─ owned/borrowed/must-dispose
│ ├─ event subscription lifetime
│ ├─ pooled buffer lifecycle
│ └─ ValueTask/single-use constraints
│
├─ Tooling — how the discipline is enforced
│ ├─ source generators
│ ├─ Roslyn analyzers
│ ├─ code fixes
│ ├─ generated docs
│ └─ OwnIR facts
│
└─ Future — horizon, not committed
├─ .own DSL
├─ F# generator/backend
├─ interop analyzers
└─ multi-language frontends
The first four pillars share one restraint: brands, refinements, and units lower to plain structs/records plus smart constructors; unions lower to a sealed hierarchy / closed struct union; protocols are enforced as consume-on-transition typestate rules — in every case the discipline is enforced by analyzer, not by a second type checker that could drift from the affine core (the project's standing meta-irony).
-
Branded / opaque types. Distinguish
ProductId,DeclarationId,Emailthough all arestringunderneath, soGetProduct(declarationId)is a diagnostic, not a 2 a.m. incident. DSL:brand ProductId : string;. C#:[OwnBrand("ProductId")]on areadonly record structplus a smart constructor; the analyzer enforces that the wrapped value only enters through it. (Mechanically these are phantom types — see the deferred catalog.) -
Refinement types — "int, but valid":
refinement Port : int where value >= 1 && value <= 65535;,refinement NonEmptyString : string where !String.IsNullOrWhiteSpace(value);,Age = int where 0 <= value <= 130,Percentage = number where 0..100. This replaces the scatteredif (age < 0) throwrituals with one declared predicate. The C# imitation today is value objects + smart constructors; we make it declarative and analyzer-checked so the predicate lives in one place and the type system, not code review, enforces the boundary. -
Units of measure, à la F#
[<Measure>]:unit kg; unit usd; unit kzt;sometres + secondsandusd + kgare compile errors. For money, currency, tax rates, and physical quantities — the domains where a silent unit mix-up is a financial bug, not a rounding one. -
Strongly typed IDs. Not a new mechanism — the single most-applied instance of branded types, called out because it is the pattern developers reach for first:
brand OrderId : Guid; brand CustomerId : Guid;soGetOrder(customerId)is caught even though both brands share the same underlyingGuid. The analyzer's job here is narrower than general branding: catch argument-order transposition at call sites where two branded IDs of the same underlying type are adjacent parameters — the concrete bug this pillar exists to kill.
-
Discriminated unions. A closed set of shapes a value can take:
union Shape { Circle(radius: float); Rect(w: float, h: float); }. Lowers to a sealed hierarchy (or a source-generated closed struct union) that cannot be extended from outside the declaration — the analyzer, notsealedalone, enforces closedness across partial classes and other-assembly subclassing attempts. -
Exhaustive matching. C#'s
switchover a non-enumtype has no exhaustiveness check at all, and even enum switches only get an ignorableCS8509warning. Own.Types promotes this to a hard diagnostic tied to theuniondeclaration: everyswitch/pattern match over a branded union must cover every case or an explicitdefault/discard, and a new case added to the union must break every non-exhaustive match site at compile time, not at 3 a.m. in production. -
Option. Replaces the gap nullable reference types leave open (nothing stops astring?from silently meaning "not yet loaded" and "deliberately absent" and "error", all at once).Option<T>is a two-state union (Some/None); the analyzer flags unmatched.Valueaccess the same way it flags a non-exhaustive union match — this pillar is a specialization of #6, not a separate mechanism. -
Result/ error unions.Result<T, E>as the alternative to exceptions-as-control-flow for expected failure. Two enforcement angles: (a) the exhaustiveness rule from #6 — aResultmust be matched on bothOkandError, not just unwrapped; (b) an "unobserved result" diagnostic, structurally the same shape as an unawaitedTask— aResultthat is constructed and never matched or propagated is silently swallowed failure.
Pillar 2's four items are one mechanism wearing three hats: a closed-shape
declaration plus an exhaustiveness check. Option and Result are simply the
one- and two-error-case unions developers reach for constantly enough to name
directly, rather than making every call site spell out a bespoke union.
-
Typestate / protocols. Encode object state in the type so methods can only be called in a valid order:
protocol Report { state Draft; state Validated; state Built; validate: Draft -> Validated; build: Validated -> Built; export: Built -> File use !Log; }Examples:
Connection<Closed|Open>,Transaction<Started|Committed|RolledBack>,Json<Raw|Parsed|Validated>(you cannot save unvalidated JSON). This composes directly with the ownership/affine core: a transition can consume self, socommit: Started -> Committedconsumingtxmakes a subsequentrollback(tx)reject — rollback-after-commit is not a runtime guard, it is a use-after-move. Typestate is also the generalization that subsumes session types (typed message-ordering protocols) as the special case where the object is a channel.First slice, built. Not the
protocoldeclaration above but its C# surface: a state is a[ProtocolToken]ref struct over an ordinary entity, a[ProtocolRegion]method opens an exclusive region and hands out the token, and a transition is a method on the token. The Roslyn frontend lowers a region toborrow_mutand a token copy tomove(OwnIR v1,spec/OwnIR.md§5.3); the existing ownership core gives the verdicts — a stale token is OWN002, a copied one OWN005, a raw touch of the entity inside the region OWN013 — on both engines. It works on the instance an ORM tracks, with no base class, repository or custom LINQ (frontend/roslyn/protocol-samples/efcore). What the slice does not have: a declared state graph (the transitions are whatever the tokens' methods are), a generator for the tokens, the affine view of a token (an unspent one is OWN001 today), and any claim over writes that bypass the entity's C# surface. -
Owned / borrowed / must-dispose. Already built as a standalone diagnostic in P-005 — Own.NET already treats
IDisposableas typestate C# lacks. This pillar's job is not to duplicate P-005's checker; it is to surface the same ownership verdict as a type-level marker in the signature a developer reads (Owned<T>,Borrowed<T>,[OwnMustDispose]) so the discipline is visible at the call site, not only in an analyzer squiggle. -
Event subscription lifetime. Already covered as a resource-lifetime profile in P-004 (WPF) and P-006 (DI lifetime, where a subscription is one captive-dependency shape). Own.Types' angle: a typed subscription handle that is itself a two-state protocol (
Active -> Disposed, unsubscribe consumes self), so double-unsubscribe and use-after-unsubscribe fall out of the same typestate mechanism as #9, instead of a bespokeSUB0xxrule. -
Pooled buffer lifecycle. Already covered in P-007 (ArrayPool/Span borrow-view). Own.Types' angle: model a rented buffer as
Buffer<Rented|Returned>— a two-state protocol exactly like #9 — so "view survivesReturn" is reported through the general typestate/use-after-move path rather than a parallel pool-specific engine. -
ValueTask/single-use constraints. A well-known .NET footgun:ValueTaskmust be awaited (or converted) exactly once, and never both stored and awaited. Structurally this is a single-transition protocol (Pending -> Awaited; awaiting twice, or awaiting after.AsTask(), is use-after-move) — the same affine "consume once" mechanism as #9, applied to a BCL type Own.NET does not own and cannot annotate at the source, so the marker has to live at the call site ([OwnSingleUse]on the producing member, or an analyzer-only rule with no DSL declaration).
Pillars 10–13 are not new checkers to build — they are existing or near-existing lifetime facts (P-004/005/006/007) reframed as instances of the two general Own.Types mechanisms (ownership marker, typestate protocol). The payoff of doing this pillar at all is unification: one mental model (protocol state, consume-on-transition) instead of four bespoke rule families.
-
Source generators. Emit the boilerplate a brand/refinement/union declaration implies — smart constructors, equality,
ToString, exhaustiveMatch/Switchhelper methods — so the discipline costs one declaration, not hand-written ceremony per type. -
Roslyn analyzers. The enforcement side for every pillar above: brand- boundary violations (#1), predicate bypass (#2), unit mismatch (#3), non-exhaustive match (#6), unobserved
Result(#8), invalid protocol transition / use-after-transition (#9, #11–#13). -
Code fixes. A matching quick-fix per analyzer: insert the missing
switcharm stub for a non-exhaustive match, wrap a raw literal in its brand's smart constructor, insert the missingDispose/transition call. Diagnostics without a code fix push the discipline back onto the developer manually re-deriving the fix; that is the gap this item exists to close. -
Generated docs. Render the
.owndeclarations (brand / refinement / union / protocol) into human-readable reference pages, the same wayownlang/diagnostics.py(TITLES) is the single source of truth forOWNcodes today, withspec/Diagnostics.mdas its human-readable grouping — one declaration, read by the compiler and the wiki, so the contract and its documentation cannot drift apart. -
OwnIR facts. The seam every pillar above lowers through: new OwnIR fact kinds (
brand,refinement,union,protocol-state) alongside the existing resource/ownership facts inspec/OwnIR.md, so any frontend — today's Roslyn C# extractor, tomorrow's OwnTS/OwnJava/OwnKotlin (P-017) — emits and consumes the same domain-type vocabulary without re-deriving it per language.
The combined picture — domain types, refinements, unions, resources, protocol state, and effects in one signature set:
brand ProductId : string;
brand OrderId : Guid; brand CustomerId : Guid;
refinement NonEmptyString : string where !String.IsNullOrWhiteSpace(value);
unit usd;
union PaymentResult { Approved(txId: string); Declined(reason: string); }
resource Db; resource ArrayPool<T>;
protocol Report {
state Draft; state Validated; state Built;
validate: Draft -> Validated;
build: Validated -> Built;
export: Built -> File use !Log;
}
fn CalculateTotal(order: Order) -> Money pure;
fn LoadOrder(id: ProductId) -> Order use Db;
fn Charge(customer: CustomerId, amount: usd) -> PaymentResult use Db;
fn RenderReport(report: Report<Validated>) -> File use !ArrayPool<byte>, !Log;
Distinct from the deferred catalog below: these are things Own.Types plausibly grows into, not type-theory tempo it is refusing.
.ownDSL. Today's brand/refinement/union/protocol snippets are sketch syntax inside this proposal, not a ratified grammar. Graduating this pillar means these constructs get a real entry inspec/Grammar.md, with the same test-pinned discipline as every other DSL construct.- F# generator/backend. F#'s discriminated unions, units of measure, and
records are a structural match for pillars 1–2 — a codegen backend that lowers
.owndeclarations to idiomatic F# (real DUs, real[<Measure>]) instead of a C# analyzer shim, for teams that can host an F# core project inside a C# solution and want the compiler itself enforcing the discipline. - Interop analyzers. Once an F# backend exists, the boundary itself needs checking: a value crossing from a real F# DU into the C#-side shim representation must stay branded and exhaustive across the language edge, not just within one language.
- Multi-language frontends. Ties directly to P-017 — Own.Types facts (#1–#13) become one more fact family the OwnTS/OwnJVM frontends emit over the same OwnIR seam (#18) the ownership facts already use.
Refuse the boil-the-ocean version. The first move is explicitly not dependent
types, GADTs, or higher-kinded types — that way lies a tower of type-level
arithmetic (башня type-level арифметики) where you wanted to write a function and
end up proving 2 + 2 = 4. The DSL must not become a new general-purpose language;
it stays a spec/model/contract layer. No new runtime, no rewriting the codebase —
brands, refinements, and unions lower to plain structs/records and smart
constructors, and the discipline is enforced by analyzer, not by a parallel type
checker that drifts from the core (the project's standing meta-irony).
Algebraic domain modeling (pillar 2) gets the same restraint: exhaustive matching
is enforcement of existing C# switch/pattern-match syntax, not a new
pattern-matching language grafted on top of it. [OwnIgnore("reason")] remains
the escape hatch.
Surveyed and explicitly not first — recorded so the ideas aren't lost:
- Dependent types (
Vector<int, 3>— length in the type;dot: Vector<float, N> -> Vector<float, N> -> float). Maximum strictness, maximum cognitive cost. Idris / Agda / Coq / Lean / F*. Not a contract layer; a proof obligation. - GADTs (typed AST:
Expr<Int>/Expr<Bool>,Add: Expr<Int> -> Expr<Int> -> Expr<Int>). Only if a typed AST / DSL / query-builder need appears — relevant to the Snipper / Reactor / AST-transform ideas, not before. - Phantom types — already in scope, as the underlying mechanism behind brands (pillar 1, including strongly typed IDs).
- Higher-kinded types (abstract over
F<_>: Functor / Monad). Do not touch: assembling a spaceship out ofIEnumerable,Task, and pain. - Row types ("an object with at least these fields"), existential types
("there is some hidden
T" — plugin/handler systems, heterogeneous collections), intersectionA & B/ unionA | Btypes (the type-theory union, distinct from pillar 2's closed-shapeuniondeclaration), and gradual typing (strict + dynamic mixed; the risk isanyspreading until the type system is a decorative quality sticker). - Modal types (
Html<Trusted|Untrusted>,Sql<Untrusted|Parameterized>,sanitize: Html<Untrusted> -> Html<Trusted>) and indexed types (Buffer<Initialized|Uninitialized>,Password<PlainText|Hash>, pipeline stages) — both for trust zones, escaping, and lifecycle. These overlap heavily with branded + typestate, so they may fall out for free once those two land.
Priority, most-applied → academic tail: branded/opaque · units of measure · typestate · refinement · discriminated unions/exhaustive matching · effect types (P-008) · session types · phantom, then dependent / GADT / HKT as the cognitively expensive end.
- Surface: analyzer-only (annotate C# in place) vs
.ownspec + source generator vs both. Brands, refinements, and unions want a generator (smart constructors, exhaustive-match helpers); typestate wants the analyzer + the affine core. - Where do brands live relative to P-006 capabilities — is a capability just
a branded, non-
Copyresource token, or its own kind? - Refinement strength: syntactic predicate enforced at the constructor boundary (cheap, sound-by-construction) vs flow-checked refinement (needs the verification backend, P-002). v0 should be the former.
- Typestate ↔ ownership seam: confirm transitions express consume-self
through the existing affine facts, so
committhenrollbackis reported as use-after-move by the one core — no second mechanism. Pillar 3's items 10–13 are the concrete test of this seam: each must reduce to it, not spawn a parallel one. - Do modal/indexed types ever need their own surface, or are they always reducible to brand + typestate in practice?
- Diagnostic prefix.
DI,EFF, andOBLare established per-pillar families (seeownlang/diagnostics.py). Does Own.Types reserveTYP0xxthe same way, or fold into coreOWNcodes with a[type: …]kind tag mirroring the existing[resource: …]tag? ReservingTYPnow avoids repeating theWPFxxx-catalog-vs-emitted-code confusion recorded indocs/notes/consolidation-and-positioning.md. - Do discriminated-union exhaustiveness (#6) and
Option/Resultunwrap-safety (#7, #8) stay one analyzer rule family or split into separate rules sharing only the source-generator scaffolding?