Sitelet https://github.com/fsprojects/FSharp.Data.GraphQL/pull/628
Skip to content

Compare types instead of their names to recognize options and lists - #628

Open
xperiandri wants to merge 2 commits into
devfrom
reflection-type-comparison
Open

xperiandri wants to merge 2 commits into
devfrom
reflection-type-comparison

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

The checks for options, value options, skippable values and F# lists compared the full name of a type with the name of the generic type definition through the culture-sensitive String.StartsWith. Input coercion runs them on every coerced value, and execution on every resolved nullable field through Helpers.objectOptionCast. They also took an array of options for an option, since its full name starts with the same name.

This PR compares the types themselves with Type.(=). #625 builds on it.

Benchmark

Each way to tell whether a type is an F# option, measured with BenchmarkDotNet 0.15 on .NET 10, x64 (the source is below):

Check int option int option[] int voption List<int> string
FullName.StartsWith name (before) 653 ns 708 ns 584 ns 248 ns 227 ns
FullName.StartsWith (name, Ordinal) 3.5 ns 1.2 ns 1.2 ns 1.2 ns 0.5 ns
GetGenericTypeDefinition().FullName = name 6.2 ns 0.36 ns 2.0 ns 2.4 ns 0.35 ns
GetGenericTypeDefinition () = definition (F# equality) 11.4 ns 1.2 ns 10.4 ns 10.2 ns 1.2 ns
Object.ReferenceEquals (GetGenericTypeDefinition (), definition) 1.3 ns 0.33 ns 1.3 ns 1.3 ns 0.6 ns

A second run compared the type comparisons with Type.(=), the Type.op_Equality operator. Other processes loaded the machine during it, so its absolute numbers are higher than above, but the three methods ran under the same load:

Check int option int option[] int voption List<int> string
GetGenericTypeDefinition () = definition (F# equality) 9.5 ns 3.8 ns 25.6 ns 14.0 ns 3.5 ns
Type.(=) (GetGenericTypeDefinition (), definition) (after) 2.6 ns 0.69 ns 3.3 ns 5.7 ns 0.85 ns
Object.ReferenceEquals (GetGenericTypeDefinition (), definition) 3.8 ns 1.4 ns 3.4 ns 4.1 ns 0.79 ns

None of them allocates. Type.(=) is as fast as comparing references and stays correct for any Type implementation, while the F# equality of types is several times slower, even slower than comparing the names ordinally. The Copilot instructions now require Type.(=) for comparing types. An end-to-end run of SimpleExecutionBenchmark was inconclusive: other processes kept the machine 30–70% busy, and the standard deviation reached 20–60% of the mean.

Benchmark source
module Program

open System
open BenchmarkDotNet.Attributes
open BenchmarkDotNet.Running

[<Literal>]
let OptionTypeName = "Microsoft.FSharp.Core.FSharpOption`1"

let optionDefinition = typedefof<_ option>

[<MemoryDiagnoser>]
type OptionTypeCheck () =

    [<ParamsSource("Types")>]
    member val Case = "" with get, set

    member _.Types = [ "int option"; "int voption"; "int option[]"; "string"; "List<int>" ]

    member val Type : Type = null with get, set

    [<GlobalSetup>]
    member this.Setup () =
        this.Type <-
            match this.Case with
            | "int option" -> typeof<int option>
            | "int voption" -> typeof<int voption>
            | "int option[]" -> typeof<int option[]>
            | "string" -> typeof<string>
            | _ -> typeof<Collections.Generic.List<int>>

    [<Benchmark(Baseline = true)>]
    member this.FullNameStartsWith () = this.Type.FullName.StartsWith OptionTypeName

    [<Benchmark>]
    member this.FullNameStartsWithOrdinal () = this.Type.FullName.StartsWith (OptionTypeName, StringComparison.Ordinal)

    [<Benchmark>]
    member this.DefinitionFullNameEquals () =
        this.Type.IsGenericType
        && String.Equals (this.Type.GetGenericTypeDefinition().FullName, OptionTypeName, StringComparison.Ordinal)

    [<Benchmark>]
    member this.DefinitionEquals () =
        this.Type.IsGenericType
        && this.Type.GetGenericTypeDefinition () = optionDefinition

    [<Benchmark>]
    member this.DefinitionTypeEquality () =
        this.Type.IsGenericType
        && Type.(=) (this.Type.GetGenericTypeDefinition (), optionDefinition)

    [<Benchmark>]
    member this.DefinitionReferenceEquals () =
        this.Type.IsGenericType
        && Object.ReferenceEquals (this.Type.GetGenericTypeDefinition (), optionDefinition)

[<EntryPoint>]
let main args =
    BenchmarkRunner.Run<OptionTypeCheck> () |> ignore
    0

Changes

  • ReflectionHelper.isConstructedFrom compares the generic type definition of a type with Type.(=), and isOptionType, isValueOptionType, isSkippableType and isListType build on it. They replace every comparison of names in Helpers, the server ReflectionHelper, Values and the ObjectListFilter middleware's TypeCoercion, and the type name literals are removed.
  • Helpers.unwrap and Helpers.objectOptionCast no longer take an array of options for an option, for which they threw a NullReferenceException.
  • The System.Array`1 check of isAssignableWithUnwrap is dropped: no array type has that name, so it never matched. Input coercion builds an array only for a GraphQL list whose type is an array, which is assignable without it, so matching arrays there would only pass a list to an array parameter.
  • Values keeps its two comparisons of the short names, inputType.Name <> outputType.Name. They compare whole strings ordinally, which is fast, and comparing the types instead would change which values get wrapped.
  • The Copilot instructions require Type.(=) for comparing types, in a commit of its own.

Breaking changes

The old checks took an int option[] constructor parameter for an optional one, so a nullable GraphQL field bound to it passed the schema validation, while the same field bound to an int option list always failed it with InvalidInputTypeException. Both now fail. Such a parameter must be declared as an option of the array, such as int option[] option. The release notes list it.

Verification

  • FSharp.Data.GraphQL.Tests in Release: 769 passed, 5 skipped, 0 failed. The new ReflectionHelperTests cover the type checks, the helpers on an array of options, an input object with an int option[] field, and the rejected nullable field. On dev, the helper test fails with NullReferenceException, and an int option list parameter fails with the same InvalidInputTypeException the int option[] one now fails with.
  • Release build of FSharp.Data.GraphQL.slnx with SDK 10.0.401: 0 warnings, 0 errors

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 4, 2026 14:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No blocking issues remain; regression tests cover the corrected array handling and documented validation change.

Review effort: Balanced
Findings: None

What changed in this PR

Replaces name-prefix checks with generic type identity comparisons, improving performance and correctly distinguishing options from arrays of options.

Changes:

  • Centralizes type checks across shared helpers, server coercion, and middleware.
  • Adds regression tests for option arrays and nullable-field validation.
  • Documents the breaking validation change and helper fixes.
File Description
tests/​FSharp.Data.GraphQL.Tests/​Helpers and Extensions/​ReflectionHelperTests.fs Tests type recognition, helpers, and input validation.
tests/​FSharp.Data.GraphQL.Tests/​FSharp.Data.GraphQL.Tests.fsproj Includes the new tests.
src/​FSharp.Data.GraphQL.Shared/​Helpers/​Reflection.fs Adds identity checks and updates option helpers.
src/​FSharp.Data.GraphQL.Server/​Values.fs Uses identity checks during coercion.
src/​FSharp.Data.GraphQL.Server/​ReflectionHelper.fs Updates optionality and collection checks.
src/​FSharp.Data.GraphQL.Server.Middleware/​TypeCoercion.fs Updates middleware wrapper recognition.
RELEASE_NOTES.md Documents fixes and the compatibility change.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Test Results

    9 files      9 suites   14m 28s ⏱️
  882 tests   877 ✅  5 💤 0 ❌
2 646 runs  2 631 ✅ 15 💤 0 ❌

Results for commit cd7e32d.

♻️ This comment has been updated with latest results.

xperiandri and others added 2 commits October 4, 2026 22:19
The checks for options, value options, skippable values and F# lists
compared the full name of a type with the name of the generic type
definition through the culture-sensitive `String.StartsWith`, which takes
hundreds of nanoseconds per check, on every coerced input value and every
resolved nullable field. The full name of an array of options starts with
the same name, so these checks also took `int option[]` for an option:
`Helpers.unwrap` and `Helpers.objectOptionCast` threw a
`NullReferenceException` for it, and an `int option[]` constructor
parameter was taken for an optional one.

`ReflectionHelper.isConstructedFrom` compares the generic type definition
with `Type.(=)`, which takes about a nanosecond, while the F# equality of
types takes about ten. `isOptionType`, `isValueOptionType`,
`isSkippableType` and `isListType` replace every comparison of names, and
the type name literals are removed.

The ``System.Array`1`` check of `isAssignableWithUnwrap` never matched, as
no array type has that name, and is dropped: input coercion builds an array
only for a GraphQL list whose type is an array, which is assignable
without it.

Breaking change: a nullable GraphQL field bound to an array of options
must be declared as an option of the array, as a list of options always
had to be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The F# equality of types goes through generic equality and is several times
slower than `Type.op_Equality`, and comparing the names of types is slow
and takes an array of a generic type for the generic type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants