From 6924ec476a8e7215bd24a12b86f6eed1090fe786 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:27:18 +0000 Subject: [PATCH 1/2] Fix option<'T> not recognized as union type in reflection (fixes #4082) FSharpType.IsUnion/GetUnionCases now correctly treats option<'T> as a union type with two cases (None=tag 0, Some=tag 1), matching .NET behaviour. FSharpValue.GetUnionFields and MakeUnion are updated to handle the erased runtime representation of options (None = undefined in JS/TS, None = None in Python; Some wraps via the Some helper). Changes: - src/fable-library-ts/Reflection.ts: add cases to option_type, handle option in getUnionFields and makeUnion - src/fable-library-py/fable_library/reflection.py: same fixes for Python using SomeWrapper awareness - tests/Js/Main/ReflectionTests.fs: add regression test - tests/Python/TestReflection.fs: add regression test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Fable.Cli/CHANGELOG.md | 1 + src/Fable.Compiler/CHANGELOG.md | 1 + .../fable_library/reflection.py | 23 +++++++++++++--- src/fable-library-ts/Reflection.ts | 27 ++++++++++++++++++- tests/Js/Main/ReflectionTests.fs | 18 +++++++++++++ tests/Python/TestReflection.fs | 19 +++++++++++++ 6 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index ebb7b8e650..b16109a8c2 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [Python] Fix missing `Array` module implementations and tests (by @ncave) * [Python] Fix object expressions implementing interfaces with `[]` members no longer produce unimplementable abstract Protocol members (fixes #3039) * [Python] Fix `DateTime.TryParse` incorrectly assigning `DateTimeKind.Local` to naive datetime strings (should be `DateTimeKind.Unspecified`) (fixes #3654) +* [JS/TS/Python] Fix `FSharpType.IsUnion` and `FSharpType.GetUnionCases` not recognising `option<'T>` as a union type; also fix `FSharpValue.GetUnionFields` and `FSharpValue.MakeUnion` for option values (fixes #4082) ## 5.0.0-rc.7 - 2026-04-07 diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index a0bf3c55e0..34c21d8d41 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [Python] Fix missing `Array` module implementations and tests (by @ncave) * [Python] Fix object expressions implementing interfaces with `[]` members no longer produce unimplementable abstract Protocol members (fixes #3039) * [Python] Fix `DateTime.TryParse` incorrectly assigning `DateTimeKind.Local` to naive datetime strings (should be `DateTimeKind.Unspecified`) (fixes #3654) +* [JS/TS/Python] Fix `FSharpType.IsUnion` and `FSharpType.GetUnionCases` not recognising `option<'T>` as a union type; also fix `FSharpValue.GetUnionFields` and `FSharpValue.MakeUnion` for option values (fixes #4082) ## 5.0.0-rc.13 - 2026-04-07 diff --git a/src/fable-library-py/fable_library/reflection.py b/src/fable-library-py/fable_library/reflection.py index 8366b29421..1227099d59 100644 --- a/src/fable-library-py/fable_library/reflection.py +++ b/src/fable-library-py/fable_library/reflection.py @@ -7,6 +7,7 @@ from .array_ import Array from .core import FSharpRef, int32 +from .core import option as option_module from .record import Record from .types import IntegerTypes from .union import Union @@ -111,7 +112,13 @@ def anon_record_type(*fields: FieldInfo) -> TypeInfo: def option_type(generic: TypeInfo) -> TypeInfo: - return TypeInfo("Microsoft.FSharp.Core.FSharpOption`1", Array([generic])) + def make_cases() -> list[CaseInfo]: + return [ + CaseInfo(t, 0, "None", []), + CaseInfo(t, 1, "Some", [("value", generic)]), + ] + t = TypeInfo("Microsoft.FSharp.Core.FSharpOption`1", Array([generic]), None, None, None, make_cases) + return t def list_type(generic: TypeInfo) -> TypeInfo: @@ -427,6 +434,10 @@ def make_union(uci: CaseInfo, values: Array[Any]) -> Any: if len(values) != expectedLength: raise ValueError(f"Expected an array of length {expectedLength} but got {len(values)}") + # Special handling for option types + if uci.declaringType.fullname == "Microsoft.FSharp.Core.FSharpOption`1": + return None if uci.tag == 0 else option_module.some(values[0]) + # Use case constructor if available (new tagged_union pattern) if uci.case_constructor is not None: return uci.case_constructor(*values) @@ -442,10 +453,16 @@ def get_union_cases(t: TypeInfo) -> Array[CaseInfo]: raise ValueError(f"{t.fullname} is not an F# union type") -def get_union_fields(v: Union, t: TypeInfo) -> tuple[CaseInfo, Array[Any]]: +def get_union_fields(v: Any, t: TypeInfo) -> tuple[CaseInfo, Array[Any]]: cases: Array[CaseInfo] = get_union_cases(t) + # Special handling for option types (None is Python None, Some wraps value) + if t.fullname == "Microsoft.FSharp.Core.FSharpOption`1": + if v is None: + return (cases[0], Array[Any]([])) # None case + else: + inner = v.value if isinstance(v, option_module.SomeWrapper) else v + return (cases[1], Array[Any]([inner])) # Some case case: CaseInfo = cases[v.tag] - return (case, Array[Any](v.fields)) diff --git a/src/fable-library-ts/Reflection.ts b/src/fable-library-ts/Reflection.ts index 001d9b43a2..3b9e55735b 100644 --- a/src/fable-library-ts/Reflection.ts +++ b/src/fable-library-ts/Reflection.ts @@ -1,6 +1,7 @@ import { FSharpRef, Record, Union } from "./Types.ts"; import { Exception, MutableArray, combineHashCodes, equalArraysWith, IEquatable, stringHash } from "./Util.ts"; import Decimal from "./Decimal.ts"; +import { Some, some } from "./Option.ts"; export type FieldInfo = [string, TypeInfo]; export type PropertyInfo = FieldInfo; @@ -155,7 +156,18 @@ export function lambda_type(argType: TypeInfo, returnType: TypeInfo): TypeInfo { } export function option_type(generic: TypeInfo): TypeInfo { - return new TypeInfo("Microsoft.FSharp.Core.FSharpOption`1", [generic]); + const t: TypeInfo = new TypeInfo( + "Microsoft.FSharp.Core.FSharpOption`1", + [generic], + undefined, + undefined, + undefined, + () => [ + new CaseInfo(t, 0, "None"), + new CaseInfo(t, 1, "Some", [["value", generic]]) + ] + ); + return t; } export function list_type(generic: TypeInfo): TypeInfo { @@ -443,6 +455,15 @@ export function isFunction(t: TypeInfo): boolean { export function getUnionFields(v: any, t: TypeInfo): [CaseInfo, any[]] { const cases = getUnionCases(t); + // Special handling for option types (None is undefined, Some is the value or a Some wrapper) + if (t.fullname === "Microsoft.FSharp.Core.FSharpOption`1") { + if (v == null) { + return [cases[0], []]; // None case + } else { + const innerValue = v instanceof Some ? v.value : v; + return [cases[1], [innerValue]]; // Some case + } + } const case_ = cases[v.tag]; if (case_ == null) { throw new Exception(`Cannot find case ${v.name} in union type`); @@ -478,6 +499,10 @@ export function makeUnion(uci: CaseInfo, values: MutableArray): any { if (values.length !== expectedLength) { throw new Exception(`Expected an array of length ${expectedLength} but got ${values.length}`); } + // Special handling for option types + if (uci.declaringType.fullname === "Microsoft.FSharp.Core.FSharpOption`1") { + return uci.tag === 0 ? undefined : some(values[0]); + } const construct = uci.declaringType.construct; if (construct == null) { return {}; diff --git a/tests/Js/Main/ReflectionTests.fs b/tests/Js/Main/ReflectionTests.fs index 180c4908d2..d63f6ea3f5 100644 --- a/tests/Js/Main/ReflectionTests.fs +++ b/tests/Js/Main/ReflectionTests.fs @@ -562,6 +562,24 @@ let reflectionTests = [ FSharpValue.MakeUnion(ucis.[0], [|box 5|]) |> equal (box (Result<_,string>.Ok 5)) FSharpValue.MakeUnion(ucis.[1], [|box "foo"|]) |> equal (box (Result.Error "foo")) + // See https://github.com/fable-compiler/Fable/issues/4082 + testCase "FSharp.Reflection: Option is a union type" <| fun () -> + let typ = typeof + FSharpType.IsUnion(typ) |> equal true + let ucis = FSharpType.GetUnionCases(typ) + ucis.Length |> equal 2 + ucis.[0].Name |> equal "None" + ucis.[1].Name |> equal "Some" + FSharpValue.MakeUnion(ucis.[0], [||]) |> equal (box (None: int option)) + FSharpValue.MakeUnion(ucis.[1], [|box 42|]) |> equal (box (Some 42)) + let noneCase, noneFields = FSharpValue.GetUnionFields(None: int option |> box, typ) + noneCase.Name |> equal "None" + noneFields.Length |> equal 0 + let someCase, someFields = FSharpValue.GetUnionFields(Some 42 |> box, typ) + someCase.Name |> equal "Some" + someFields.Length |> equal 1 + someFields.[0] |> equal (box 42) + testCase "FSharp.Reflection: Choice" <| fun () -> let typ = typeof> let ucis = FSharpType.GetUnionCases typ diff --git a/tests/Python/TestReflection.fs b/tests/Python/TestReflection.fs index 5c29a5aa0c..a64724a054 100644 --- a/tests/Python/TestReflection.fs +++ b/tests/Python/TestReflection.fs @@ -468,6 +468,25 @@ let ``test FSharp.Reflection: Result`` () = FSharpValue.MakeUnion(ucis.[0], [|box 5|]) |> equal (box (Result<_,string>.Ok 5)) FSharpValue.MakeUnion(ucis.[1], [|box "foo"|]) |> equal (box (Result.Error "foo")) +// See https://github.com/fable-compiler/Fable/issues/4082 +[] +let ``test FSharp.Reflection: Option is a union type`` () = + let typ = typeof + FSharpType.IsUnion(typ) |> equal true + let ucis = FSharpType.GetUnionCases(typ) + ucis.Length |> equal 2 + ucis.[0].Name |> equal "None" + ucis.[1].Name |> equal "Some" + FSharpValue.MakeUnion(ucis.[0], [||]) |> equal (box (None: int option)) + FSharpValue.MakeUnion(ucis.[1], [|box 42|]) |> equal (box (Some 42)) + let noneCase, noneFields = FSharpValue.GetUnionFields(None: int option |> box, typ) + noneCase.Name |> equal "None" + noneFields.Length |> equal 0 + let someCase, someFields = FSharpValue.GetUnionFields(Some 42 |> box, typ) + someCase.Name |> equal "Some" + someFields.Length |> equal 1 + someFields.[0] |> equal (box 42) + [] let ``test FSharp.Reflection: Choice`` () = let typ = typeof> From 790143404b655bfc29061416212db8ee5bb3c2c1 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Mon, 27 Apr 2026 23:26:01 +0200 Subject: [PATCH 2/2] fix(js/ts/python): address review feedback on option reflection - Revert CHANGELOG edits (auto-generated from commit history per AGENTS.md) - Fix unparseable type annotation in tests (None: int option |> box) - Use back-fill pattern in option_type to avoid forward-reference closure - Drop redundant option_module alias and unused Union import - Add tests for nested options (Some None, Some (Some x)) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Fable.Cli/CHANGELOG.md | 1 - src/Fable.Compiler/CHANGELOG.md | 1 - .../fable_library/reflection.py | 31 +++++++++---------- tests/Js/Main/ReflectionTests.fs | 16 ++++++++-- tests/Python/TestReflection.fs | 17 ++++++++-- 5 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index b16109a8c2..ebb7b8e650 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -17,7 +17,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [Python] Fix missing `Array` module implementations and tests (by @ncave) * [Python] Fix object expressions implementing interfaces with `[]` members no longer produce unimplementable abstract Protocol members (fixes #3039) * [Python] Fix `DateTime.TryParse` incorrectly assigning `DateTimeKind.Local` to naive datetime strings (should be `DateTimeKind.Unspecified`) (fixes #3654) -* [JS/TS/Python] Fix `FSharpType.IsUnion` and `FSharpType.GetUnionCases` not recognising `option<'T>` as a union type; also fix `FSharpValue.GetUnionFields` and `FSharpValue.MakeUnion` for option values (fixes #4082) ## 5.0.0-rc.7 - 2026-04-07 diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index 34c21d8d41..a0bf3c55e0 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -17,7 +17,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * [Python] Fix missing `Array` module implementations and tests (by @ncave) * [Python] Fix object expressions implementing interfaces with `[]` members no longer produce unimplementable abstract Protocol members (fixes #3039) * [Python] Fix `DateTime.TryParse` incorrectly assigning `DateTimeKind.Local` to naive datetime strings (should be `DateTimeKind.Unspecified`) (fixes #3654) -* [JS/TS/Python] Fix `FSharpType.IsUnion` and `FSharpType.GetUnionCases` not recognising `option<'T>` as a union type; also fix `FSharpValue.GetUnionFields` and `FSharpValue.MakeUnion` for option values (fixes #4082) ## 5.0.0-rc.13 - 2026-04-07 diff --git a/src/fable-library-py/fable_library/reflection.py b/src/fable-library-py/fable_library/reflection.py index 1227099d59..76d6e18948 100644 --- a/src/fable-library-py/fable_library/reflection.py +++ b/src/fable-library-py/fable_library/reflection.py @@ -6,11 +6,9 @@ from typing import Any, cast from .array_ import Array -from .core import FSharpRef, int32 -from .core import option as option_module +from .core import FSharpRef, int32, option from .record import Record from .types import IntegerTypes -from .union import Union from .union import Union as FsUnion from .util import combine_hash_codes, equal_arrays_with @@ -112,12 +110,11 @@ def anon_record_type(*fields: FieldInfo) -> TypeInfo: def option_type(generic: TypeInfo) -> TypeInfo: - def make_cases() -> list[CaseInfo]: - return [ - CaseInfo(t, 0, "None", []), - CaseInfo(t, 1, "Some", [("value", generic)]), - ] - t = TypeInfo("Microsoft.FSharp.Core.FSharpOption`1", Array([generic]), None, None, None, make_cases) + t = TypeInfo("Microsoft.FSharp.Core.FSharpOption`1", Array([generic])) + t.cases = lambda: [ + CaseInfo(t, 0, "None", []), + CaseInfo(t, 1, "Some", [("value", generic)]), + ] return t @@ -434,9 +431,9 @@ def make_union(uci: CaseInfo, values: Array[Any]) -> Any: if len(values) != expectedLength: raise ValueError(f"Expected an array of length {expectedLength} but got {len(values)}") - # Special handling for option types + # Options are erased at runtime: None -> None, Some(x) -> x or SomeWrapper if uci.declaringType.fullname == "Microsoft.FSharp.Core.FSharpOption`1": - return None if uci.tag == 0 else option_module.some(values[0]) + return None if uci.tag == 0 else option.some(values[0]) # Use case constructor if available (new tagged_union pattern) if uci.case_constructor is not None: @@ -453,16 +450,18 @@ def get_union_cases(t: TypeInfo) -> Array[CaseInfo]: raise ValueError(f"{t.fullname} is not an F# union type") +# `v` is `Any` because option values are erased at runtime (None / raw value / +# SomeWrapper) and don't share a base class with `Union`. def get_union_fields(v: Any, t: TypeInfo) -> tuple[CaseInfo, Array[Any]]: cases: Array[CaseInfo] = get_union_cases(t) - # Special handling for option types (None is Python None, Some wraps value) + # Options are erased at runtime: None -> None, Some(x) -> x or SomeWrapper if t.fullname == "Microsoft.FSharp.Core.FSharpOption`1": if v is None: - return (cases[0], Array[Any]([])) # None case - else: - inner = v.value if isinstance(v, option_module.SomeWrapper) else v - return (cases[1], Array[Any]([inner])) # Some case + return (cases[0], Array[Any]([])) + inner = v.value if isinstance(v, option.SomeWrapper) else v + return (cases[1], Array[Any]([inner])) case: CaseInfo = cases[v.tag] + return (case, Array[Any](v.fields)) diff --git a/tests/Js/Main/ReflectionTests.fs b/tests/Js/Main/ReflectionTests.fs index d63f6ea3f5..20cdbda9c6 100644 --- a/tests/Js/Main/ReflectionTests.fs +++ b/tests/Js/Main/ReflectionTests.fs @@ -572,14 +572,26 @@ let reflectionTests = [ ucis.[1].Name |> equal "Some" FSharpValue.MakeUnion(ucis.[0], [||]) |> equal (box (None: int option)) FSharpValue.MakeUnion(ucis.[1], [|box 42|]) |> equal (box (Some 42)) - let noneCase, noneFields = FSharpValue.GetUnionFields(None: int option |> box, typ) + let noneCase, noneFields = FSharpValue.GetUnionFields(box (None: int option), typ) noneCase.Name |> equal "None" noneFields.Length |> equal 0 - let someCase, someFields = FSharpValue.GetUnionFields(Some 42 |> box, typ) + let someCase, someFields = FSharpValue.GetUnionFields(box (Some 42), typ) someCase.Name |> equal "Some" someFields.Length |> equal 1 someFields.[0] |> equal (box 42) + testCase "FSharp.Reflection: Option round-trips through Some(None) and Some(Some x)" <| fun () -> + let typ = typeof + let ucis = FSharpType.GetUnionCases(typ) + let someCase, someFields = FSharpValue.GetUnionFields(box (Some (None: int option)), typ) + someCase.Name |> equal "Some" + someFields.[0] |> equal (box (None: int option)) + let someCase2, someFields2 = FSharpValue.GetUnionFields(box (Some (Some 42)), typ) + someCase2.Name |> equal "Some" + someFields2.[0] |> equal (box (Some 42)) + FSharpValue.MakeUnion(ucis.[1], [|box (None: int option)|]) |> equal (box (Some (None: int option))) + FSharpValue.MakeUnion(ucis.[1], [|box (Some 42)|]) |> equal (box (Some (Some 42))) + testCase "FSharp.Reflection: Choice" <| fun () -> let typ = typeof> let ucis = FSharpType.GetUnionCases typ diff --git a/tests/Python/TestReflection.fs b/tests/Python/TestReflection.fs index a64724a054..21a1527926 100644 --- a/tests/Python/TestReflection.fs +++ b/tests/Python/TestReflection.fs @@ -479,14 +479,27 @@ let ``test FSharp.Reflection: Option is a union type`` () = ucis.[1].Name |> equal "Some" FSharpValue.MakeUnion(ucis.[0], [||]) |> equal (box (None: int option)) FSharpValue.MakeUnion(ucis.[1], [|box 42|]) |> equal (box (Some 42)) - let noneCase, noneFields = FSharpValue.GetUnionFields(None: int option |> box, typ) + let noneCase, noneFields = FSharpValue.GetUnionFields(box (None: int option), typ) noneCase.Name |> equal "None" noneFields.Length |> equal 0 - let someCase, someFields = FSharpValue.GetUnionFields(Some 42 |> box, typ) + let someCase, someFields = FSharpValue.GetUnionFields(box (Some 42), typ) someCase.Name |> equal "Some" someFields.Length |> equal 1 someFields.[0] |> equal (box 42) +[] +let ``test FSharp.Reflection: Option round-trips through Some(None) and Some(Some x)`` () = + let typ = typeof + let ucis = FSharpType.GetUnionCases(typ) + let someCase, someFields = FSharpValue.GetUnionFields(box (Some (None: int option)), typ) + someCase.Name |> equal "Some" + someFields.[0] |> equal (box (None: int option)) + let someCase2, someFields2 = FSharpValue.GetUnionFields(box (Some (Some 42)), typ) + someCase2.Name |> equal "Some" + someFields2.[0] |> equal (box (Some 42)) + FSharpValue.MakeUnion(ucis.[1], [|box (None: int option)|]) |> equal (box (Some (None: int option))) + FSharpValue.MakeUnion(ucis.[1], [|box (Some 42)|]) |> equal (box (Some (Some 42))) + [] let ``test FSharp.Reflection: Choice`` () = let typ = typeof>