CHR0048: A clear is declared for a member that cannot hold null
Rule Description
Section titled “Rule Description”Clearing a read model member means returning it to no value. A member declared non-nullable has no such state, so the declaration has nothing correct to write.
The rule fires on every spelling of a clear — the model-bound [ClearWith<TEvent>] on a member and [SetValue<TEvent>(null)], and the fluent .Clear(...) and .Set(...).ToValue(null) — when the member cannot hold null:
- a reference type declared non-nullable in a nullable-aware context (
string, notstring?); - any value type that is not
Nullable<T>(int,DateTimeOffset, an enum, aConceptAs<T>struct).
A reference type in a file that has opted out of nullable analysis is oblivious rather than non-null. That promises nothing, so it is left alone — and the projection builder makes the same call at runtime.
Not reported: a class-level [ClearWith] on a nested type, or one on the member holding it. Those clear a whole nested object, which is nullable by construction.
Severity
Section titled “Severity”Warning — scheduled to become an error in the next major release.
Warning rather than error for its first release, following the precedent recorded on the retired CHR0047: these analyzers ship inside the Cratis.Chronicle package, so under TreatWarningsAsErrors — the Cratis default — a new warning already breaks a consumer build on upgrade. An error is a strictly stronger break and buys little, because the declaration it reports never worked in the first place.
The declaration has no correct reading, so the escalation to an error is announced here rather than sprung later. Treat this warning as a build failure in waiting, and note that it is the softer of the two signals: building the projection refuses the declaration outright (see below), so leaving the warning unaddressed fails at application startup instead of at build time.
Example
Section titled “Example”using Cratis.Chronicle.Events;using Cratis.Chronicle.Keys;using Cratis.Chronicle.Projections.ModelBound;
[EventType]public record Chr0048SliceCommandCleared();
[EventType]public record Chr0048SliceCommandSet(string Command, int Attempts);
public record Chr0048Slice( [Key] string Id,
// Warning CHR0048: 'Command' is declared as a non-nullable string, so there is no value the clear // could write except the empty string - a different fact the read model cannot tell apart from a // real value. Declare it as string? to clear it, or say what you mean with [SetValue<T>("")]. [SetFrom<Chr0048SliceCommandSet>(nameof(Chr0048SliceCommandSet.Command))] [ClearWith<Chr0048SliceCommandCleared>] string Command,
// Warning CHR0048: a value type cannot hold null whatever the nullable context says, and zero is a // count rather than the absence of one. Declare it as int? to clear it. [SetFrom<Chr0048SliceCommandSet>(nameof(Chr0048SliceCommandSet.Attempts))] [SetValue<Chr0048SliceCommandCleared>(null)] int Attempts);
// The declarations that are correct: both members can hold the value a clear writes, so both spellings// of the clear are accepted and the member really does go back to no value - replay included.public record Chr0048SliceFixed( [Key] string Id,
[SetFrom<Chr0048SliceCommandSet>(nameof(Chr0048SliceCommandSet.Command))] [ClearWith<Chr0048SliceCommandCleared>] string? Command,
[SetFrom<Chr0048SliceCommandSet>(nameof(Chr0048SliceCommandSet.Attempts))] [SetValue<Chr0048SliceCommandCleared>(null)] int? Attempts);Why This Rule Exists
Section titled “Why This Rule Exists”The only value a projection could write to a non-nullable member is its type default — an empty string, a zero, DateTimeOffset.MinValue. That is a different fact from “not set”, and the read model cannot tell the two apart: a reader seeing "" cannot know whether the value was cleared or genuinely empty.
Standing a type default in for “not set” is precisely the workaround the scalar clear exists to remove. Consumers used to invent type-specific sentinels because Chronicle had no clear; defining clear as the sentinel would ship the workaround under a name that promises something else. So the declaration is refused rather than reinterpreted.
No shipping code can depend on this declaration working. Until a scalar clear existed, [ClearWith] on a member and a null [SetValue] were both reported as inert by the retired CHR0047 — the projection emitted no mapping at all for the member — and the fluent ToValue(null) silently wrote the four-character string "null". There is no reading under which the declaration was what its author meant.
The same rule is enforced when the projection is built, where it throws CannotClearNonNullableMember — for the model-bound attributes and for the fluent .Clear(...) alike. That covers what the analyzer never sees — a read model from an assembly compiled without the Chronicle analyzers, or one whose diagnostic was suppressed — so the failure is loud at startup rather than a quiet type default in storage.
The rule could not be moved into the type system. C# cannot express “a nullable-annotated reference type” as a generic constraint: a non-nullable argument converts to a nullable parameter without complaint, so Clear<TProperty>(Expression<Func<TReadModel, TProperty?>>) accepts m => m.NonNullableName silently. Constraining to struct does refuse a non-nullable value type at compile time, but only that half, and it reports as CS0411 — type arguments cannot be inferred, which explains nothing about the actual problem. One rule that says what is wrong beats a compile error for half the cases and silence for the rest.
How To Fix It
Section titled “How To Fix It”Decide which fact you actually mean:
- You mean “no value”. Declare the member nullable —
string?,int?,DateTimeOffset?— and keep the clear. - You mean a specific value. Use
[SetValue<TEvent>("")],[SetValue<TEvent>(0)], or whatever the value is. It says so in the declaration, and a reader of the read model can see it was set on purpose.
Related Rules
Section titled “Related Rules”- CHR0047: A clear declaration is never applied by projection construction — retired; this rule took over the shapes that are still wrong.
- CHR0024: Read model property has no mapping source — a member whose only declaration is a clear has no source and is still reported.