From 8a80e7eb927fce64b6a36821c3ed79654a84900a Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Fri, 7 Aug 2026 14:18:28 +0200 Subject: [PATCH 1/5] Add Unchecked.withNull for unconstrained C# nullable-generic interop Adds an inline escape hatch re-typing 'T to 'T | null with no not null / not struct constraint, so unconstrained C# nullable-generic APIs (T? M()) can be implemented and consumed from F#. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: acdc940e-b98f-4072-b143-a3e296e12fcb --- docs/release-notes/.FSharp.Core/11.0.100.md | 5 ++ src/FSharp.Core/prim-types.fs | 3 + src/FSharp.Core/prim-types.fsi | 12 ++++ .../Nullness/NullableCsharpImportTests.fs | 62 ++++++++++++++++++- ...p.Core.SurfaceArea.netstandard20.debug.bsl | 1 + ...Core.SurfaceArea.netstandard20.release.bsl | 1 + ...p.Core.SurfaceArea.netstandard21.debug.bsl | 1 + ...Core.SurfaceArea.netstandard21.release.bsl | 1 + 8 files changed, 84 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/.FSharp.Core/11.0.100.md b/docs/release-notes/.FSharp.Core/11.0.100.md index 3349ac75260..d4e82e684a3 100644 --- a/docs/release-notes/.FSharp.Core/11.0.100.md +++ b/docs/release-notes/.FSharp.Core/11.0.100.md @@ -4,3 +4,8 @@ * Fix `Array.exists2` documentation examples to use equal-length arrays; the previous examples would throw `ArgumentException` at runtime instead of returning the documented `false`/`true` values. ([PR #19672](https://github.com/dotnet/fsharp/pull/19672)) * Move `Async.StartChild` to the "Starting Async Computations" docs category alongside `Async.StartChildAsTask`. ([Issue #19667](https://github.com/dotnet/fsharp/issues/19667)) * Add `InlineIfLambda` to `Array.init` ([PR #19869](https://github.com/dotnet/fsharp/pull/19869)) + +### Added + +* Add `Unchecked.withNull`, an interop escape hatch that re-types any `'T` to `'T | null` without the usual `not null`/`not struct` constraints, so unconstrained C# nullable-generic APIs (e.g. `T? M()`) can be implemented and consumed from F#. ([Issue #17734](https://github.com/dotnet/fsharp/issues/17734), [PR #PENDING](https://github.com/dotnet/fsharp/pull/PENDING)) + diff --git a/src/FSharp.Core/prim-types.fs b/src/FSharp.Core/prim-types.fs index 036ba49ce48..f58002127d5 100644 --- a/src/FSharp.Core/prim-types.fs +++ b/src/FSharp.Core/prim-types.fs @@ -5554,6 +5554,9 @@ namespace Microsoft.FSharp.Core [] let inline (|NonNullQuick|) (value : 'T | null when 'T : not null and 'T : not struct) = nonNull value + [] + let inline withNull (value: 'T) : 'T | null = (# "" value : 'T | null #) + module Checked = let inline (+) (x: ^T) (y: ^U) : ^V = diff --git a/src/FSharp.Core/prim-types.fsi b/src/FSharp.Core/prim-types.fsi index 82ab0584ca8..c7320663673 100644 --- a/src/FSharp.Core/prim-types.fsi +++ b/src/FSharp.Core/prim-types.fsi @@ -5834,6 +5834,18 @@ namespace Microsoft.FSharp.Core [] val inline (|NonNullQuick|) : value: 'T | null -> 'T when 'T : not null and 'T : not struct + /// Re-types a value of any type into the nullable type ('T | null). This exists purely to satisfy + /// interoperability with C# APIs that expose an unconstrained nullable generic, for example a method + /// T? M<T>() or an interface member such as T? GetValue<T>(int index). + /// Unlike , this places no + /// not null or not struct constraint on 'T. Regular F# code does not do this, because 'T | null + /// is meaningless when 'T is a struct: a value type has no separate null value, so the annotation carries no + /// runtime meaning and is erased. Use this only to meet an interop signature. This is an unsafe operation. + /// The value. + /// The same value, re-typed as 'T | null. + [] + val inline withNull<'T> : value: 'T -> 'T | null + /// A module of comparison and equality operators that are statically resolved, but which are not fully generic and do not make structural comparison. Opening this /// module may make code that relies on structural or generic comparison no longer compile. module NonStructuralComparison = diff --git a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NullableCsharpImportTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NullableCsharpImportTests.fs index 0284263d714..553ed2a8ed2 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NullableCsharpImportTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NullableCsharpImportTests.fs @@ -259,5 +259,63 @@ let theOtherOne = NullableClass.nullableImmArrayOfNotNullStrings |> shouldFail |> withDiagnostics [Error 3261, Line 7, Col 18, Line 7, Col 29, "Nullness warning: Possible dereference of a null value when accessing member 'Length' on the nullable value 'firstString' of type 'string | null'."] - - + +// https://github.com/dotnet/fsharp/issues/17734 +[] +let ``Unchecked.withNull implements an unconstrained C# nullable generic member`` () = + let csharpLib = + CSharp """ +#nullable enable +namespace Interop { + public interface IEventContext { + T? GetValue(int index); + } +}""" |> withName "csEventContext" + |> withCSharpLanguageVersionPreview + + FSharp """module MyLibrary +open Interop + +let ctx = + { new IEventContext with + member _.GetValue(index) = Unchecked.withNull (Unchecked.defaultof<_>) } +""" + |> asLibrary + |> withReferences [csharpLib] + |> withStrictNullness + |> compile + |> shouldSucceed + +[] +let ``Unchecked.withNull null assignment through generic layers is valid IL for structs`` () = + FSharp """module MyProgram +let observe (v: 'T) : objnull = + let mutable x = Unchecked.withNull v + x <- null + box x + +let forward (v: 'T) = observe v + +[] +let main _ = + System.Console.Write(sprintf "%A %b" (forward 42) (isNull (forward "hello"))) + 0 +""" + |> withStrictNullness + |> compileExeAndRun + |> shouldSucceed + |> withStdOutContains "0 true" + +[] +let ``Unchecked.withNull does not allow assigning null to a concrete struct mutable`` () = + FSharp """module MyLibrary +let f () = + let mutable x = Unchecked.withNull 42 + x <- null +""" + |> asLibrary + |> withStrictNullness + |> typecheck + |> shouldFail + |> withErrorCode 43 + diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.debug.bsl b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.debug.bsl index 5b6cc0bce4e..4d3b5f07528 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.debug.bsl +++ b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.debug.bsl @@ -1821,6 +1821,7 @@ Microsoft.FSharp.Core.Operators+Unchecked: T DefaultOf[T]() Microsoft.FSharp.Core.Operators+Unchecked: T NonNullQuickPattern[T](T) Microsoft.FSharp.Core.Operators+Unchecked: T NonNull[T](T) Microsoft.FSharp.Core.Operators+Unchecked: T Unbox[T](System.Object) +Microsoft.FSharp.Core.Operators+Unchecked: T WithNull[T](T) Microsoft.FSharp.Core.Operators: Boolean IsNullV[T](System.Nullable`1[T]) Microsoft.FSharp.Core.Operators: Boolean IsNull[T](T) Microsoft.FSharp.Core.Operators: Boolean Not(Boolean) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.release.bsl b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.release.bsl index 217d4b7c837..15ce5ef3ebc 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.release.bsl +++ b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.release.bsl @@ -1821,6 +1821,7 @@ Microsoft.FSharp.Core.Operators+Unchecked: T DefaultOf[T]() Microsoft.FSharp.Core.Operators+Unchecked: T NonNullQuickPattern[T](T) Microsoft.FSharp.Core.Operators+Unchecked: T NonNull[T](T) Microsoft.FSharp.Core.Operators+Unchecked: T Unbox[T](System.Object) +Microsoft.FSharp.Core.Operators+Unchecked: T WithNull[T](T) Microsoft.FSharp.Core.Operators: Boolean IsNullV[T](System.Nullable`1[T]) Microsoft.FSharp.Core.Operators: Boolean IsNull[T](T) Microsoft.FSharp.Core.Operators: Boolean Not(Boolean) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bsl b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bsl index 43defdb622e..ce2a641d849 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bsl +++ b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bsl @@ -1824,6 +1824,7 @@ Microsoft.FSharp.Core.Operators+Unchecked: T DefaultOf[T]() Microsoft.FSharp.Core.Operators+Unchecked: T NonNullQuickPattern[T](T) Microsoft.FSharp.Core.Operators+Unchecked: T NonNull[T](T) Microsoft.FSharp.Core.Operators+Unchecked: T Unbox[T](System.Object) +Microsoft.FSharp.Core.Operators+Unchecked: T WithNull[T](T) Microsoft.FSharp.Core.Operators: Boolean IsNullV[T](System.Nullable`1[T]) Microsoft.FSharp.Core.Operators: Boolean IsNull[T](T) Microsoft.FSharp.Core.Operators: Boolean Not(Boolean) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.release.bsl b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.release.bsl index ed913ea04d3..f4762853d2b 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.release.bsl +++ b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.release.bsl @@ -1824,6 +1824,7 @@ Microsoft.FSharp.Core.Operators+Unchecked: T DefaultOf[T]() Microsoft.FSharp.Core.Operators+Unchecked: T NonNullQuickPattern[T](T) Microsoft.FSharp.Core.Operators+Unchecked: T NonNull[T](T) Microsoft.FSharp.Core.Operators+Unchecked: T Unbox[T](System.Object) +Microsoft.FSharp.Core.Operators+Unchecked: T WithNull[T](T) Microsoft.FSharp.Core.Operators: Boolean IsNullV[T](System.Nullable`1[T]) Microsoft.FSharp.Core.Operators: Boolean IsNull[T](T) Microsoft.FSharp.Core.Operators: Boolean Not(Boolean) From f55fb9f08fd571f2f4fc56e7209a62c3ba0d6645 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Fri, 7 Aug 2026 14:18:55 +0200 Subject: [PATCH 2/5] Fill PR link in release note Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: acdc940e-b98f-4072-b143-a3e296e12fcb --- docs/release-notes/.FSharp.Core/11.0.100.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.FSharp.Core/11.0.100.md b/docs/release-notes/.FSharp.Core/11.0.100.md index d4e82e684a3..c84d8cbc2db 100644 --- a/docs/release-notes/.FSharp.Core/11.0.100.md +++ b/docs/release-notes/.FSharp.Core/11.0.100.md @@ -7,5 +7,5 @@ ### Added -* Add `Unchecked.withNull`, an interop escape hatch that re-types any `'T` to `'T | null` without the usual `not null`/`not struct` constraints, so unconstrained C# nullable-generic APIs (e.g. `T? M()`) can be implemented and consumed from F#. ([Issue #17734](https://github.com/dotnet/fsharp/issues/17734), [PR #PENDING](https://github.com/dotnet/fsharp/pull/PENDING)) +* Add `Unchecked.withNull`, an interop escape hatch that re-types any `'T` to `'T | null` without the usual `not null`/`not struct` constraints, so unconstrained C# nullable-generic APIs (e.g. `T? M()`) can be implemented and consumed from F#. ([Issue #17734](https://github.com/dotnet/fsharp/issues/17734), [PR #20232](https://github.com/dotnet/fsharp/pull/20232)) From 9ed97b2dae723b962e92dee38261211fe2023a04 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 10 Aug 2026 09:36:34 +0200 Subject: [PATCH 3/5] Make interop test use the explicit generic member form from the issue Faithfully reproduces jwosty's IEventContext.GetValue<'T> attempt: the explicit generic member with an inferred 'T | null return now compiles clean under warnaserror:FS3261 thanks to Unchecked.withNull, where bare Unchecked.defaultof reports FS3261. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: acdc940e-b98f-4072-b143-a3e296e12fcb --- .../Language/Nullness/NullableCsharpImportTests.fs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NullableCsharpImportTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NullableCsharpImportTests.fs index 553ed2a8ed2..f3900c89b38 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NullableCsharpImportTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NullableCsharpImportTests.fs @@ -260,7 +260,10 @@ let theOtherOne = NullableClass.nullableImmArrayOfNotNullStrings |> withDiagnostics [Error 3261, Line 7, Col 18, Line 7, Col 29, "Nullness warning: Possible dereference of a null value when accessing member 'Length' on the nullable value 'firstString' of type 'string | null'."] -// https://github.com/dotnet/fsharp/issues/17734 +// https://github.com/dotnet/fsharp/issues/17734#issuecomment-5197965168 +// Implementing a C#-authored interface whose member returns an unconstrained 'T | null +// (e.g. SocketIO's IEventContext.GetValue). Writing the generic member with Unchecked.defaultof +// alone reports FS3261; withNull re-types it to the expected 'T | null without adding constraints. [] let ``Unchecked.withNull implements an unconstrained C# nullable generic member`` () = let csharpLib = @@ -278,7 +281,7 @@ open Interop let ctx = { new IEventContext with - member _.GetValue(index) = Unchecked.withNull (Unchecked.defaultof<_>) } + member _.GetValue<'T>(index: int) = Unchecked.withNull (Unchecked.defaultof<'T>) } """ |> asLibrary |> withReferences [csharpLib] From 4d35cd4ff58e4676b1082f979e77eb704449fe81 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 10 Aug 2026 10:08:12 +0200 Subject: [PATCH 4/5] Align Unchecked.withNull doc with Unchecked.nonNull; explain unsafety Mirror the sibling nonNull "Unsafely retypes ... This is an unsafe operation." wording, and spell out why withNull is unsafe (it bypasses the not null / not struct constraints, so 'T | null can be formed for a struct where null is not representable and degrades to defaultof) and that it exists for interop with unconstrained C# T? generics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: acdc940e-b98f-4072-b143-a3e296e12fcb --- src/FSharp.Core/prim-types.fsi | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/FSharp.Core/prim-types.fsi b/src/FSharp.Core/prim-types.fsi index c7320663673..1c2d6e2d433 100644 --- a/src/FSharp.Core/prim-types.fsi +++ b/src/FSharp.Core/prim-types.fsi @@ -5834,15 +5834,12 @@ namespace Microsoft.FSharp.Core [] val inline (|NonNullQuick|) : value: 'T | null -> 'T when 'T : not null and 'T : not struct - /// Re-types a value of any type into the nullable type ('T | null). This exists purely to satisfy - /// interoperability with C# APIs that expose an unconstrained nullable generic, for example a method - /// T? M<T>() or an interface member such as T? GetValue<T>(int index). - /// Unlike , this places no - /// not null or not struct constraint on 'T. Regular F# code does not do this, because 'T | null - /// is meaningless when 'T is a struct: a value type has no separate null value, so the annotation carries no - /// runtime meaning and is erased. Use this only to meet an interop signature. This is an unsafe operation. + /// Unsafely retypes the value from 'T to ('T | null), bypassing the 'not null' and 'not struct' constraints that F# otherwise requires in order to write ('T | null). This is an unsafe operation. + /// This exists purely for interoperability with C# APIs that expose an unconstrained nullable generic, such as a method T? M<T>() or an interface member T? GetValue<T>(int index) where T has no class constraint and can therefore also be a struct. Without it such a signature cannot be implemented or consumed from F# without spurious FS3261 nullness warnings. + /// + /// It is unsafe precisely because it sidesteps those constraints. Unlike it adds no not null or not struct constraint, so the resulting ('T | null) can be formed even when 'T is a struct, where null is not a representable value: there the annotation carries no runtime meaning and is erased, and assigning null to such a location yields Unchecked.defaultof<'T> rather than a true null. Use it only to satisfy an interop signature. /// The value. - /// The same value, re-typed as 'T | null. + /// The same value, retyped as ('T | null). [] val inline withNull<'T> : value: 'T -> 'T | null From 78f2a8311b8ba22ce6ef0d32fcbb4e392d90537b Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Mon, 10 Aug 2026 12:41:01 +0200 Subject: [PATCH 5/5] Re-run CI (flaky console-coloring help test) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: acdc940e-b98f-4072-b143-a3e296e12fcb