Skip to content

Let a null be the result of a transformation (breaking) #2481

Description

@danielcweber

A transformation cannot answer with null today. This issue proposes to let it, which is a breaking change to ExRam.Gremlinq.Core's public contract.

Today

  • ITransformer.TryTransform and IConverter.TryConvert both declare [NotNullWhen(true)] out TTarget? value: success promises a value that is not null.
  • Transformer enforces it. A converter's answer only counts when TryConvert(...) is true && optionValue is not null (src/Core/Transformation/Transformer.cs). A converter that answers "success, null" is treated as if it had declined, and the next converter is asked.
  • GraphsonSupportTestBase.Nullable_null_at_top_level documents this for int?: at the top level there is nothing to hold a null, TryTransform cannot report one, and a successful null is discarded, "being indistinguishable from a decline".

What follows from it

Measured on both Support.NewtonsoftJson and Support.SystemTextJson (Gremlinq.Extensions), on 14.x plus #2477 and Gremlinq/Gremlinq.Extensions#214:

Input Answer today
null as string, Uri, Person, int?, object[], a dictionary declines; TransformTo<T>().From(...) throws InvalidCastException
[ "a", null ] as string[] [ a, null ]. The array decides for itself and keeps the null
a member or constructor argument that is null left unset
{ "key": "k", "value": null } as Property<string>; the same for VertexProperty<string> throws ArgumentNullException from the Property<T> constructor
{ "a": "x", "b": null } as Dictionary<string, string> Newtonsoft keeps b: null; System.Text.Json drops the entry. As a g:Map, both drop it
{ "a": 1, "b": null } as Dictionary<string, int> Newtonsoft throws ArgumentNullException; System.Text.Json drops the entry
a null inside a map or object that is read as object; { "@type": "g:Int32", "@value": null } as object not null, but each implementation's own token: a JValue from Newtonsoft, a JsonElement from System.Text.Json

The last row is the clearest symptom. Because null cannot be a result, the chain of converters falls through to the fallback that hands back the token it was given.

#2477 pins the first three rows as today's contract. The Property<T> and dictionary rows are not pinned.

Proposal

Let null be a legitimate result of a transformation:

  • TryTransform and TryConvert may answer true with a null value when the requested type can hold one (a reference type or Nullable<T>). The [NotNullWhen(true)] annotations go.
  • Transformer no longer discards a successful null.
  • A JSON null requested as a type that can hold it is answered with null, at the top level and inside structures alike. A type that cannot hold it (int) still declines.

What breaks

  • Public API. Two annotations on public interfaces change. Callers that dereference value after true get nullable warnings, and TransformTo<T>().From(...) can return null for a T that is not annotated as nullable. This needs a major version.
  • Converters that answer "success, null" to pass on. Today that is harmless, because Transformer moves on to the next converter; NullableConverter does it. Afterwards the first such answer ends the chain. Every converter in both implementations has to be checked for it.
  • Custom converters registered by users that rely on the same behaviour.
  • Tests. The tests in Say that a null is no string, except where an array or an object holds it #2477 that pin "declines" flip to a null result: String_from_null, Uri_from_null, Person_from_null, String_from_single_item_array_with_null, String_from_typed_value_with_null, String_from_Property_with_null_value, String_from_VertexProperty_with_null_value, Property_of_string_from_null, VertexProperty_of_string_from_null, and Nullable_null_at_top_level.

To decide along the way

  • Whether Property<T> and VertexProperty<T> accept a null value, or a property whose value is null is declined. Their constructors throw on null today.
  • What a dictionary does with a null value: keep the entry, in both implementations and for both plain objects and g:Map.
  • Whether a member that is null in the JSON is assigned null or left at its initializer. The two are only different for a member with a non-null initializer.

Both implementations have to change together: Support.NewtonsoftJson here, and Support.SystemTextJson in Gremlinq.Extensions.

Activity

  1. danielcweber commented on Oct 2, 2026

    @danielcweber
    ContributorAuthor

    Some items of "To decide along the way" now have their own issues. These issues do not need the breaking change:

    • A null dictionary value: Gremlinq keeps it when the value type can hold null, for a plain object and for a g:Map. The dictionary converter decides this itself, as the array converters do for a null item. See Newtonsoft drops a null value from a g:Map #2492 (N) and Gremlinq/Gremlinq.Extensions#230 (STJ).
    • Property<T> and VertexProperty<T> with a null value: the converters decline the token and do not throw. See Newtonsoft throws for some input that it cannot read #2486 (N, Core) and Gremlinq/Gremlinq.Extensions#234 (STJ). This issue can still decide later to accept null as a value.

    A decision about the last row of the table ("each implementation's own token"):

    • A Newtonsoft token (JValue, JObject) can be the result when it can stand in for the requested type. Callers use these tokens, and they stay usable. A change would break these callers.
    • A JsonElement must never be the result. It becomes unusable when the memory of its JsonDocument is released. See Let a serializer stop the transformer from giving its own token as the result #2495 (Core) and Gremlinq/Gremlinq.Extensions#229 (STJ).
  2. danielcweber commented on Oct 9, 2026

    @danielcweber
    ContributorAuthor

    Update after #2486, #2492 and Gremlinq/Gremlinq.Extensions#230, #234 landed (2026-10-09). The table under "Today" has changed in these rows, for both implementations:

    Input Answer now
    { "key": "k", "value": null } as Property<string>; the same for VertexProperty<string> declined; nothing throws
    { "a": "x", "b": null } as Dictionary<string, string>, as a plain object and as a g:Map b: null is kept
    { "a": 1, "b": null } as Dictionary<string, int> the entry b is left out; nothing throws
    a null value in a plain object or a g:Map read as Dictionary<string, object>, IDictionary or object a real null, no JValue and no JsonElement

    Two corrections to the comment above:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions