Why JsonRequired Can't Depend on Another Property
A configuration type has a discriminator and a property that only matters for one of its cases. A Kind of Advanced needs a Tuning value, and a Kind of Basic doesn't. The obvious thing to reach for is [JsonRequired] with a condition attached, and no such thing exists.
The reason turns out to be a single line in how System.Text.Json builds its contracts, and once that line is clear, every possible workaround follows from it. This is the write-up of what I found building ktsu.JsonRequiredConditionally, and of the things the workaround can't do.
Where the Requirement Is Decided
[JsonRequired] maps to JsonPropertyInfo.IsRequired. That's a bool, baked into a type's contract when metadata is built, which happens before any JSON is read.
At that moment the object doesn't exist. There are no sibling values to consult, because nothing has been materialized yet. A condition that depends on what Kind turned out to be can't be evaluated at contract-build time for the same reason a compiler can't evaluate a runtime variable.
So there's no extending the native required-check. It isn't a missing feature or an oversight in the API surface, it's a consequence of the requirement being a property of the contract rather than a property of the payload. Anything conditional has to run somewhere the object already exists, and in System.Text.Json that means a converter.
The Converter Route, and Why It Buffers
The converter buffers its subtree, materializes it through a cached copy of the caller's options with the factory removed, then walks the materialized object graph alongside the JSON, applying rules at every level.
Removing the factory from the inner options is what stops it recursing into itself forever. Caching that copy is what stops it rebuilding the options on every deserialization, which would be a significant cost on a hot path.
The less obvious decision is that the converter validates the whole subtree itself rather than letting System.Text.Json re-enter it for nested values. That looks like duplicated work, and I'd have preferred to delegate. It can't, because converter resolution is cached per type. A self-referential type would resolve the converter once at the outermost level and then never again on the way down, so everything below the first level would go unvalidated. The bug would only appear on nested data, which is exactly the data least likely to be in the first test.
Buffering has a real cost. The subtree is read into memory before validation, so this isn't free on large payloads. Types with no decorated members are never claimed at all and keep the serializer's normal fast path, which is what keeps the cost proportional to how much of a graph actually uses the feature.
Taking the Member Model From the Serializer
The walk takes its member model from JsonTypeInfo.Properties rather than from reflection.
Anyone rewriting the library should preserve this decision. JsonTypeInfo.Properties is System.Text.Json's own view of which members it populates. Using it means [JsonIgnore], [JsonInclude] on non-public members, get-only properties, and constructor binding all behave the same way in validation as they do in deserialization, automatically and permanently.
Reflection would produce a second, independent model of which members count. It would agree with the serializer most of the time, and the cases where it disagreed would be silent. A property the serializer ignores but the validator requires produces an error nobody can act on. A property the serializer populates but the validator skips produces no error at all. Both are worse than a slower walk.
The same principle shows up in a subtler place. Whether a type is claimed at all is decided before any caller options exist, because a converter factory's CanConvert is handed only a Type. So the claim check looks for decorated members with fields included, and a plain public field carrying the attribute claims its type. But the rules themselves are compiled against the real options, so with IncludeFields off no rule is produced for that field and nothing is enforced on it, exactly as System.Text.Json wouldn't populate it. The claim is deliberately broader than the enforcement, because the claim has less information available to it.
Absent Siblings Read as Their Default
Sibling values are read from the materialized object, so a sibling missing from the payload reads as its CLR default. That produces a trap to know about before it bites.
[JsonRequiredIfSiblingIs(nameof(Kind), Kind.Basic)] // Basic == 0
public string? Name { get; set; }An empty payload, {}, leaves Kind at its default. If Basic is the enum's zero value, the sibling matches, and Name is required on a document that never mentioned Kind at all. That's technically correct and rarely what the author meant.
Distinguishing absent from defaulted needs the sibling marked [JsonRequired] or made nullable. Neither is the library's job to do automatically, because both change the contract in ways the author might not want, but the behavior is the kind of thing that belongs in the first paragraph of the documentation rather than the last.
There's a related choice on presence. For the conditional attribute, a property that's physically present passes even when its value is null, which mirrors how [JsonRequired] itself behaves. Consistency with the built-in attribute is worth more here than being right in the abstract, because a library that redefines what "required" means in the same object as the framework's own version is a library that produces diffs nobody asked for.
Widening the Attribute Value
An attribute argument can only be a compile-time constant, so it's routinely a different type from the sibling it's compared against. [JsonRequiredIfSiblingIs(nameof(Count), 1)] is an int literal, and the sibling might be a long.
So the attribute value is widened to the sibling's type before comparing. That 1 matches a long, short, byte, uint, or nint sibling. An enum sibling is matched by an int, by another enum with the same underlying value, or by the enum member's own name written as a string.
Strings stay strict. A string sibling is compared ordinally against string constants only, and a number is never formatted into a string to make it match. Loosening that would mean 1 matching "1", which is the kind of convenience that turns a typo into a silently different rule.
A pairing that could never match throws rather than quietly never firing, and so does an unresolvable sibling name. A typo'd nameof target is a coding error. Failing loudly at first use beats a validation rule that exists, looks correct in review, and never once runs.
What It Refuses to Claim
Two limitations are deliberate, and the second one is the more interesting.
Types behind a custom converter aren't validated, because System.Text.Json exposes no property model for a type that has its own converter. The walk can't descend through one. That's a straightforward consequence of the extension point.
Polymorphic hierarchies aren't claimed at all. A type carrying [JsonPolymorphic] or [JsonDerivedType], or deriving from one that does, is skipped entirely. The reason is that System.Text.Json writes and reads the type discriminator around the derived type's converter, and refuses outright when that converter is a custom one. Claiming such a type would break a working polymorphic model, on both read and write, merely by registering this library.
A library that breaks unrelated working code by being installed is worse than a library with a documented gap. The cost is no enforcement inside the hierarchy, and it extends one step further than it first appears: a container whose only route to a decorated type runs through a polymorphic member is itself left unclaimed, so violations beneath it are reported with a path rooted at the inner type rather than at the container. The requirement is still enforced, only the path prefix is lost.
The refusal is also decided by declared type participation, so a decorated class that merely implements an interface carrying [JsonDerivedType] is skipped even when it's deserialized concretely and no polymorphic dispatch ever happens. That's stricter than strictly necessary, and I chose it because the alternative is a rule that depends on runtime shape rather than declared shape, which is much harder for a reader to predict.
Practical Takeaways
[JsonRequired]is a contract-timeboolonJsonPropertyInfo, so nothing conditional on payload values can be expressed through it. Any workaround runs after materialization, which means a converter.- A converter that validates nested values has to walk them itself. Converter resolution is cached per type, so a self-referential type would go unvalidated below the outermost level.
- Take the member model from
JsonTypeInfo.Properties, not from reflection, so validation and deserialization can't disagree about which members exist. - Sibling values read as their CLR default when absent, so an enum whose zero value is meaningful will match a payload that never mentioned it.
- Refusing to claim polymorphic types keeps existing polymorphic models working. A documented gap beats breaking unrelated code at registration time.