Skip to content

Commit d78a37d

Browse files
github-actions[bot]Copilotdbrattliclaude
authored
fix(js/ts/python): Fix FSharpOption not recognized as union type in F# reflection (#4529)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Dag Brattli <dag@brattli.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7e75f1e commit d78a37d

4 files changed

Lines changed: 108 additions & 5 deletions

File tree

src/fable-library-py/fable_library/reflection.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
from typing import Any, cast
77

88
from .array_ import Array
9-
from .core import FSharpRef, int32
9+
from .core import FSharpRef, int32, option
1010
from .record import Record
1111
from .types import IntegerTypes
12-
from .union import Union
1312
from .union import Union as FsUnion
1413
from .util import combine_hash_codes, equal_arrays_with
1514

@@ -111,7 +110,12 @@ def anon_record_type(*fields: FieldInfo) -> TypeInfo:
111110

112111

113112
def option_type(generic: TypeInfo) -> TypeInfo:
114-
return TypeInfo("Microsoft.FSharp.Core.FSharpOption`1", Array([generic]))
113+
t = TypeInfo("Microsoft.FSharp.Core.FSharpOption`1", Array([generic]))
114+
t.cases = lambda: [
115+
CaseInfo(t, 0, "None", []),
116+
CaseInfo(t, 1, "Some", [("value", generic)]),
117+
]
118+
return t
115119

116120

117121
def list_type(generic: TypeInfo) -> TypeInfo:
@@ -427,6 +431,10 @@ def make_union(uci: CaseInfo, values: Array[Any]) -> Any:
427431
if len(values) != expectedLength:
428432
raise ValueError(f"Expected an array of length {expectedLength} but got {len(values)}")
429433

434+
# Options are erased at runtime: None -> None, Some(x) -> x or SomeWrapper
435+
if uci.declaringType.fullname == "Microsoft.FSharp.Core.FSharpOption`1":
436+
return None if uci.tag == 0 else option.some(values[0])
437+
430438
# Use case constructor if available (new tagged_union pattern)
431439
if uci.case_constructor is not None:
432440
return uci.case_constructor(*values)
@@ -442,8 +450,16 @@ def get_union_cases(t: TypeInfo) -> Array[CaseInfo]:
442450
raise ValueError(f"{t.fullname} is not an F# union type")
443451

444452

445-
def get_union_fields(v: Union, t: TypeInfo) -> tuple[CaseInfo, Array[Any]]:
453+
# `v` is `Any` because option values are erased at runtime (None / raw value /
454+
# SomeWrapper) and don't share a base class with `Union`.
455+
def get_union_fields(v: Any, t: TypeInfo) -> tuple[CaseInfo, Array[Any]]:
446456
cases: Array[CaseInfo] = get_union_cases(t)
457+
# Options are erased at runtime: None -> None, Some(x) -> x or SomeWrapper
458+
if t.fullname == "Microsoft.FSharp.Core.FSharpOption`1":
459+
if v is None:
460+
return (cases[0], Array[Any]([]))
461+
inner = v.value if isinstance(v, option.SomeWrapper) else v
462+
return (cases[1], Array[Any]([inner]))
447463
case: CaseInfo = cases[v.tag]
448464

449465
return (case, Array[Any](v.fields))

src/fable-library-ts/Reflection.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FSharpRef, Record, Union } from "./Types.ts";
22
import { Exception, MutableArray, combineHashCodes, equalArraysWith, IEquatable, stringHash } from "./Util.ts";
33
import Decimal from "./Decimal.ts";
4+
import { Some, some } from "./Option.ts";
45

56
export type FieldInfo = [string, TypeInfo];
67
export type PropertyInfo = FieldInfo;
@@ -155,7 +156,18 @@ export function lambda_type(argType: TypeInfo, returnType: TypeInfo): TypeInfo {
155156
}
156157

157158
export function option_type(generic: TypeInfo): TypeInfo {
158-
return new TypeInfo("Microsoft.FSharp.Core.FSharpOption`1", [generic]);
159+
const t: TypeInfo = new TypeInfo(
160+
"Microsoft.FSharp.Core.FSharpOption`1",
161+
[generic],
162+
undefined,
163+
undefined,
164+
undefined,
165+
() => [
166+
new CaseInfo(t, 0, "None"),
167+
new CaseInfo(t, 1, "Some", [["value", generic]])
168+
]
169+
);
170+
return t;
159171
}
160172

161173
export function list_type(generic: TypeInfo): TypeInfo {
@@ -443,6 +455,15 @@ export function isFunction(t: TypeInfo): boolean {
443455

444456
export function getUnionFields(v: any, t: TypeInfo): [CaseInfo, any[]] {
445457
const cases = getUnionCases(t);
458+
// Special handling for option types (None is undefined, Some is the value or a Some wrapper)
459+
if (t.fullname === "Microsoft.FSharp.Core.FSharpOption`1") {
460+
if (v == null) {
461+
return [cases[0], []]; // None case
462+
} else {
463+
const innerValue = v instanceof Some ? v.value : v;
464+
return [cases[1], [innerValue]]; // Some case
465+
}
466+
}
446467
const case_ = cases[v.tag];
447468
if (case_ == null) {
448469
throw new Exception(`Cannot find case ${v.name} in union type`);
@@ -478,6 +499,10 @@ export function makeUnion(uci: CaseInfo, values: MutableArray<any>): any {
478499
if (values.length !== expectedLength) {
479500
throw new Exception(`Expected an array of length ${expectedLength} but got ${values.length}`);
480501
}
502+
// Special handling for option types
503+
if (uci.declaringType.fullname === "Microsoft.FSharp.Core.FSharpOption`1") {
504+
return uci.tag === 0 ? undefined : some(values[0]);
505+
}
481506
const construct = uci.declaringType.construct;
482507
if (construct == null) {
483508
return {};

tests/Js/Main/ReflectionTests.fs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,36 @@ let reflectionTests = [
562562
FSharpValue.MakeUnion(ucis.[0], [|box 5|]) |> equal (box (Result<_,string>.Ok 5))
563563
FSharpValue.MakeUnion(ucis.[1], [|box "foo"|]) |> equal (box (Result<int,_>.Error "foo"))
564564

565+
// See https://github.com/fable-compiler/Fable/issues/4082
566+
testCase "FSharp.Reflection: Option is a union type" <| fun () ->
567+
let typ = typeof<int option>
568+
FSharpType.IsUnion(typ) |> equal true
569+
let ucis = FSharpType.GetUnionCases(typ)
570+
ucis.Length |> equal 2
571+
ucis.[0].Name |> equal "None"
572+
ucis.[1].Name |> equal "Some"
573+
FSharpValue.MakeUnion(ucis.[0], [||]) |> equal (box (None: int option))
574+
FSharpValue.MakeUnion(ucis.[1], [|box 42|]) |> equal (box (Some 42))
575+
let noneCase, noneFields = FSharpValue.GetUnionFields(box (None: int option), typ)
576+
noneCase.Name |> equal "None"
577+
noneFields.Length |> equal 0
578+
let someCase, someFields = FSharpValue.GetUnionFields(box (Some 42), typ)
579+
someCase.Name |> equal "Some"
580+
someFields.Length |> equal 1
581+
someFields.[0] |> equal (box 42)
582+
583+
testCase "FSharp.Reflection: Option round-trips through Some(None) and Some(Some x)" <| fun () ->
584+
let typ = typeof<int option option>
585+
let ucis = FSharpType.GetUnionCases(typ)
586+
let someCase, someFields = FSharpValue.GetUnionFields(box (Some (None: int option)), typ)
587+
someCase.Name |> equal "Some"
588+
someFields.[0] |> equal (box (None: int option))
589+
let someCase2, someFields2 = FSharpValue.GetUnionFields(box (Some (Some 42)), typ)
590+
someCase2.Name |> equal "Some"
591+
someFields2.[0] |> equal (box (Some 42))
592+
FSharpValue.MakeUnion(ucis.[1], [|box (None: int option)|]) |> equal (box (Some (None: int option)))
593+
FSharpValue.MakeUnion(ucis.[1], [|box (Some 42)|]) |> equal (box (Some (Some 42)))
594+
565595
testCase "FSharp.Reflection: Choice" <| fun () ->
566596
let typ = typeof<Choice<int,string>>
567597
let ucis = FSharpType.GetUnionCases typ

tests/Python/TestReflection.fs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,38 @@ let ``test FSharp.Reflection: Result`` () =
468468
FSharpValue.MakeUnion(ucis.[0], [|box 5|]) |> equal (box (Result<_,string>.Ok 5))
469469
FSharpValue.MakeUnion(ucis.[1], [|box "foo"|]) |> equal (box (Result<int,_>.Error "foo"))
470470

471+
// See https://github.com/fable-compiler/Fable/issues/4082
472+
[<Fact>]
473+
let ``test FSharp.Reflection: Option is a union type`` () =
474+
let typ = typeof<int option>
475+
FSharpType.IsUnion(typ) |> equal true
476+
let ucis = FSharpType.GetUnionCases(typ)
477+
ucis.Length |> equal 2
478+
ucis.[0].Name |> equal "None"
479+
ucis.[1].Name |> equal "Some"
480+
FSharpValue.MakeUnion(ucis.[0], [||]) |> equal (box (None: int option))
481+
FSharpValue.MakeUnion(ucis.[1], [|box 42|]) |> equal (box (Some 42))
482+
let noneCase, noneFields = FSharpValue.GetUnionFields(box (None: int option), typ)
483+
noneCase.Name |> equal "None"
484+
noneFields.Length |> equal 0
485+
let someCase, someFields = FSharpValue.GetUnionFields(box (Some 42), typ)
486+
someCase.Name |> equal "Some"
487+
someFields.Length |> equal 1
488+
someFields.[0] |> equal (box 42)
489+
490+
[<Fact>]
491+
let ``test FSharp.Reflection: Option round-trips through Some(None) and Some(Some x)`` () =
492+
let typ = typeof<int option option>
493+
let ucis = FSharpType.GetUnionCases(typ)
494+
let someCase, someFields = FSharpValue.GetUnionFields(box (Some (None: int option)), typ)
495+
someCase.Name |> equal "Some"
496+
someFields.[0] |> equal (box (None: int option))
497+
let someCase2, someFields2 = FSharpValue.GetUnionFields(box (Some (Some 42)), typ)
498+
someCase2.Name |> equal "Some"
499+
someFields2.[0] |> equal (box (Some 42))
500+
FSharpValue.MakeUnion(ucis.[1], [|box (None: int option)|]) |> equal (box (Some (None: int option)))
501+
FSharpValue.MakeUnion(ucis.[1], [|box (Some 42)|]) |> equal (box (Some (Some 42)))
502+
471503
[<Fact>]
472504
let ``test FSharp.Reflection: Choice`` () =
473505
let typ = typeof<Choice<int,string>>

0 commit comments

Comments
 (0)