v0.22.0—Bounded nullability checks and practical .NET migration guidance.See what's new

Nullability and .NET Interop

Nullable references, receiving-boundary diagnostics, and explicit migration choices

Calor rejects possibly-null values at specified initialization, return and method-input boundaries. This is not whole-program null safety.

Version scope: this guide describes Calor 0.22's bounded receiving-boundary checks. Stage A established scalar string checks; Stage B adds the bounded shapes below. See Stage A evidence (#1386) and Stage B activation (#1402).

Types: nullable references are not runtime Options

str and string name the same .NET string type. ?str, ?string, str?, and string? mark a nullable string reference. ?System.String is also supported. For a resolved reference type such as Widget, ?Widget permits null without wrapping the object. Nullable value types, such as ?i32, map to .NET nullable values (int?).

Explicit Option<T> is different: it is Calor's runtime option type. §SM value and §NN construct Some and None. A nullable reference uses null and ordinary reference values instead. Neither a call nor the ?? operator implicitly unwraps an Option<T>. Choose a runtime Option when your API deliberately models Some/None, not just to silence a nullable-reference diagnostic. See the type distinction (#1397).

Supported receiving boundaries

“Active” below means an Error from the production compiler, not just a binder finding. The checks compare supported source and destination shapes; they are not general conversions between arbitrary types.

ShapeCalor 0.22 statusBoundary covered
Scalar STRING (str, string)Active since Stage AInitialization, native return, resolved method input
ArraysActive in Stage B?[str] is a nullable container; [?str] has possibly-null STRING elements. Neither satisfies [str] at these boundaries; jagged arrays retain separate container/element information
One-level string payloadsActive in Stage BMatching definitions of Option, List, IList, IEnumerable, IReadOnlyList, ICollection, IReadOnlyCollection; for example List<?str> cannot satisfy List<str>
Nominal reference typesActive in Stage B when identity is established?Widget cannot satisfy Widget when both share a resolved underlying reference identity; this is not a name-based guess that every unknown type is a reference
Other generic payloads, nested generics, unknown nominal identitiesOutside the active guaranteeAnalysis may retain type information; no promise of Calor0272–0274 coverage
Mutable assignment/rebinding and constructor inputsExcludedInitializing a mutable local is checked; later writes and §NEW arguments are not covered by this receiving-boundary guarantee

Unsupported nullability shapes may still fail ordinary type checking. Absence of one of these three diagnostics is not evidence that a value is non-null. Array-element checks do not prove that every element created by an allocation is initialized to a non-null value. Generic payload checks do not enforce the generic container's own nullability: that outer annotation is retained as information, but its receiving-boundary enforcement is not activated.

Diagnostics and complete examples

All examples below are complete files. The three marked Rejected examples are intentionally invalid. Their corrected controls use an explicit default.

CodeSeverityReceiving boundaryHighlighted source
Calor0272ErrorInitialization of a non-null bindingInitializer expression
Calor0273ErrorReturn from a non-null native function/methodReturned expression
Calor0274ErrorArgument supplied to a non-null parameterOffending argument expression

Calor0272: initialization

Rejected: input on line 4, column 22.

Calor
§M{m1:InitRejected}
  §F{f1:Copy:pub} (?str:input) -> void
    §E{}
    §B{required:str} input

Corrected: choose a meaningful default for missing input.

Calor
§M{m1:InitSafe}
  §F{f1:Copy:pub} (?str:input) -> void
    §E{}
    §B{required:str} (?? input "anonymous")

Calor0273: return

Rejected: input on line 4, column 8.

Calor
§M{m1:ReturnRejected}
  §F{f1:Name:pub} (?str:input) -> str
    §E{}
    §R input

Corrected: return a non-null fallback.

Calor
§M{m1:ReturnSafe}
  §F{f1:Name:pub} (?str:input) -> str
    §E{}
    §R (?? input "anonymous")

Calor0274: method input

Rejected: input on line 6, column 17.

Calor
§M{m1:ArgumentRejected}
  §F{f1:Take:pub} (str:value) -> void
    §E{}
  §F{f2:Send:pub} (?str:input) -> void
    §E{}
    §C{Take} §A input §/C

Corrected: default before crossing the boundary.

Calor
§M{m1:ArgumentSafe}
  §F{f1:Take:pub} (str:value) -> void
    §E{}
  §F{f2:Send:pub} (?str:input) -> void
    §E{}
    §C{Take} §A (?? input "anonymous") §/C

Statement calls and expression calls use the same argument check. Discarding the result does not waive it. Named arguments are matched to the named parameter, not to their source position:

Calor
§M{m1:NamedSafe}
  §F{f1:Choose:pub} (str:required, ?str:optional) -> str
    §E{}
    §R required
  §F{f2:Send:pub} (?str:input) -> str
    §E{}
    §R §C{Choose} §A[optional] input §A[required] "ready" §/C

For an otherwise applicable native overload, a nullable argument now yields Calor0274 rather than being discarded as an overload candidate and producing Calor0208 (“no matching overload”). Genuine overload failures still use Calor0208; runtime Option<str> is not an implicitly unwrapped string overload. This diagnostic change applies to the supported boundaries in Calor 0.22.

Defaults, throwing, and typed patterns

A default changes behavior when the input is missing. If absence is an error, throw instead. Declare the allocation and throw effects:

Calor
§M{m1:ThrowSafe}
  §F{f1:Require:pub} (?str:input) -> str
    §E{alloc,throw}
    §R (?? input §TH §NEW{ArgumentNullException})

A typed pattern binds a new non-null value in its matching arm:

Calor
§M{m1:PatternSafe}
  §F{f1:Name:pub} (?str:input) -> str
    §E{}
    §R §W{w1:expr} input
      §K §PTYPE{str:text} → text
      §K _ → "anonymous"

These are explicit consumption constructs, not whole-program flow analysis. Calor 0.22 also transfers some local guard facts, but loops, mutation, aliases, callbacks, and repeated member reads make arbitrary narrowing unsafe. Do not assume that a prior null test proves every later use safe. See working migration controls (#1398).

An early return after a null test does not currently narrow a later method argument. For an unchanged local, an explicit coalescing throw at that boundary can preserve the earlier return behavior: the throw is unreachable because the guard already returned on null. This is a manual migration, not an automatic converter rewrite.

Calling a tracked callback that may write a guarded reference invalidates the guard fact. This conservative invalidation is not assignment/rebinding receiving-boundary enforcement. Merely declaring an unused lambda does not execute its writes.

Native code, BCL members, and metadata

Native Calor return annotations describe the result at a call site. Resolved .NET methods, properties, and fields contribute their available metadata:

InformationMeaning at a supported receiving boundary
Known non-nullMay satisfy a non-null destination
Declared nullablePossibly null; consume or preserve that possibility
Truly missing annotation (Oblivious)Conservatively possibly null, not an assertion of non-null

For example, an environment variable may be absent. Preserve that possibility or choose an application-specific default:

Calor
§M{m1:EnvironmentSafe}
  §F{f1:Name:pub} () -> str
    §E{env}
    §B{input} §C{System.Environment.GetEnvironmentVariable} §A "CALOR_NAME" §/C
    §R (?? input "anonymous")

An inferred local retains supported nullability information from its initializer; omitting :str does not make a nullable result non-null. Constructor expressions can establish a non-null result without checking their inputs. That distinction matters when migrating object creation.

Metadata depends on the referenced assemblies and nullable context. Calor trusts available annotations; it does not prove an external implementation honors them. Conversion must distinguish enabled, disabled, and restored C# nullable contexts, including inherited project settings. See conversion context (#1401) and genuinely Oblivious metadata (#1400). Missing information is not an instruction to add ? to every declaration.

Migration should follow the API's meaning: preserve a nullable result if absence is legitimate, provide a meaningful default, reject absence explicitly, or correct inaccurate upstream annotations. Changing a destination to ?str only moves the obligation to its consumers. A working manual default/throw example does not establish behavior-preserving automatic conversion or automatic guard insertion.

Modes and entry points

The active diagnostics are binding errors. They survive --no-type-check, CALOR_NO_TYPE_CHECK=1, --no-enforce-effects, --permissive-effects, --no-strict-bind-inference, and --transpile-only. These controls affect different checks; they are not nullable-reference suppression switches. Verification and contract-mode choices do not bypass these boundaries either. There is no promised legacy-module warning downgrade.

The root compile command, Program.Compile, build paths using that pipeline, and calor verify retain these active errors. Editor binding diagnostics use the same active routing policy, but an editor diagnostic is not proof that the generated C# or the complete project builds. Project references and other entry-point settings still matter. See entry-point evidence (#1396).

Reproducing the examples

Save one complete example as example.calr, then run:

Bash
calor --input example.calr --output example.g.cs

Each rejected example exits 1 with its listed Error and writes no new C# output. Each safe control exits 0 and emits C#. Use a fresh output path: a failed invocation is not evidence that an older output file was deleted. NullabilityDocumentationTests reads these MDX examples and checks the production Program.Compile result, severity, span, and generated output, including type-off, effect-off, transpile-only, and verification modes. This dedicated test is not general MDX support in calor self-check docs (#1143).

This is a bounded regression set, not a corpus measurement or universal proof. Revision-specific command results belong with the source review evidence; this page does not certify a release or report corpus counts.

Verification is a separate guarantee

Receiving-boundary checks do not lift verifier proof demotions or remove runtime guards. D3 tracks nullable references, D12 tracks string indexing/counting semantics, and D14 tracks the reference model. #875 remains open; completing nullability work does not close it. Read Verification Guarantees and Limits.

See also