-
Notifications
You must be signed in to change notification settings - Fork 0
Result Design Notes
Why the library looks the way it does, and the invariants any change must preserve. If you are
about to modify Utilme.Result, read this first.
Real applications depend on this package. Every change since 1.0.1 has been additive: nothing removed, nothing renamed, no behaviour altered. The characterization test suite exists to enforce that mechanically rather than by good intentions.
Result<T>'s per-status members are inconsistently shaped:
Result<T>.NotFound // property
Result<T>.NotSupported() // methodThis cannot be fixed in place. C# forbids a member name being both a property and a method, so promoting the methods or demoting the properties requires deleting the other form — source- and binary-breaking for every caller.
The resolution was to add a clean surface beside the old one:
-
Result<T>.Fail(status, message)— the general-purpose failure factory - the non-generic
Resultclass — every status a method, value type inferred
and then hide the seven legacy members with
[EditorBrowsable(EditorBrowsableState.Never)]. They keep working; they simply stop appearing in
completion lists so new code does not reach for them.
They are deliberately not [Obsolete]. That would spam warnings through applications already
using them, for no benefit. SoftDeprecationTests pins both halves of this policy — that the seven
are hidden, and that nothing anywhere is marked obsolete.
EditorBrowsableis honoured by the IDE only for members arriving from a referenced assembly. Projects inside this solution still see them in completion, which is why the test project can keep exercising them.
Result<T>.NetworkUp() reports IsOk == false.
Semantically backwards, and permanent. Flipping it would silently invert if (result.IsOk) branches
in every consuming application — a change with no compile error to catch it and no runtime signal
that anything moved. The test NetworkUp_is_a_FAILURE_despite_its_name carries a do-not-fix comment.
Failures carrying no caller-supplied message return a shared instance, one per closed generic type:
ReferenceEquals(Result<int>.NotFound, Result<int>.NotFound) // true
ReferenceEquals(Result<int>.NotFound, Result<string>.NotFound) // false — different closed typesOk, Error and Fail(status, message) still allocate, because they carry data.
This is only safe while Result<T> is immutable. Anything added to the type must stay immutable.
Cached(status) falls back to allocating for a status it does not recognise, so appending a member
to ResultStatus cannot throw at runtime if someone forgets to register it there.
Value is T? and ErrorMessage is string?.
These are metadata-only annotations — the IL signatures are unchanged, so the change is binary
compatible. In particular T? on an unconstrained type parameter does not become Nullable<T>;
Nullable_annotations_did_not_change_the_runtime_signatures pins that.
Source-wise, consumers with nullable enabled now get CS8600/CS8603 on unguarded .Value reads for
reference types. That is intended: it surfaces the pre-existing hazard that Value returns default
on failure rather than throwing.
The numeric values are part of the contract — callers may have persisted or transmitted them as
integers. New members go at the end, never inserted or reordered.
ResultStatusCharacterizationTests freezes the values, names, count and underlying type; adding one
requires deliberately updating the count.
The project sets GenerateDocumentationFile, so every public member must carry XML docs or the
build fails its own zero-warning bar via CS1591. The docs ship inside the package, so they are what
consumers see in IntelliSense — the primary discovery mechanism for the newer API.
They are extension methods on Result<T>. Had they been in Utilme, they would have been in scope
for every existing consumer automatically — and if an application already defined its own .Match()
or .Map() extension on Result<T>, having both in scope is an ambiguity compile error, not a
warning.
Putting them in their own namespace makes importing them a deliberate act and keeps the upgrade
safe. Match (functions) and Switch (actions) are separate names rather than overloads, because a
lambda whose body is a method call would otherwise be ambiguous between them.
Not done, and each breaking:
-
Value equality — would change
HashSet/dictionary behaviour for anyone using results as keys. -
sealed— technically breaking for a derived type, however unlikely one is. -
Converting to a struct — breaks
nullchecks,default, and boxing assumptions. - Removing the legacy members — the reason the whole two-surface design exists.
- An ErrorOr interop shim — sketched in Result vs. ErrorOr.
Utilme.Result
Utilme.SdpTransform
Repository