From b1716d42c3ef884d6c03e1116a543f0354b6f604 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 9 Jul 2026 09:44:49 +0200 Subject: [PATCH 1/4] fix(python): emit forward reference for self-referencing static union members A static member whose type references the enclosing union (e.g. `Example[]`) emitted a class-body `StaticLazyProperty[Array[_Example]]` subscript. That subscript is a value expression evaluated while the class body runs, so it looked up `_Example` before the class name was bound and raised `NameError` at import time. The existing forward-reference guard only matched when the property type was directly the enclosing entity. Extend it to detect a reference nested inside `Array`, `List`, `Option`, `Tuple`, or generic args, and emit the whole annotation as a string forward reference (`StaticLazyProperty["Array[Example]"]`) which is safe at runtime and still resolved by the type checker. Fixes #4736 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Python/Fable2Python.Transforms.fs | 64 +++++++++++++++---- tests/Python/TestUnionType.fs | 19 ++++++ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index 8363698ca3..ca4852bd25 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -4761,22 +4761,60 @@ let transformStaticProperty let staticPropertyClass = libValue com ctx "util" className - // Check if the property type references the current class (forward reference needed) - let typeAnnotation = - // The property type is self-referencing when it is the enclosing entity. Compare by - // FullName as well as DisplayName: for unions the descriptor lives on the base class - // and the type alias (`name`) is only defined afterwards, so a bare name would raise - // NameError at class-body evaluation. - let isSelfReference = - match propType with - | Fable.DeclaredType(entRef, _) -> + // The `StaticProperty[T]` subscript is a value expression evaluated in the class body, so + // any name it references must already be bound. A self-referencing type breaks this: for + // unions the descriptor lives on the base class (`_Example`) and the type alias (`Example`) + // is only defined afterwards, so a bare name raises `NameError` at class-body evaluation. + // Detect a reference to the enclosing entity anywhere in the type (including nested inside + // `Array`, `Example option`, tuples, etc.) and emit the whole annotation as a + // string forward reference, which the type checker still resolves at module scope. + let referencesEnclosingEntity (propType: Fable.Type) = + let rec check (t: Fable.Type) = + match t with + | Fable.DeclaredType(entRef, genArgs) -> let e = com.GetEntity(entRef) - e.DisplayName = name || e.FullName = ent.FullName + e.DisplayName = name || e.FullName = ent.FullName || List.exists check genArgs + | Fable.Option(gen, _) + | Fable.Array(gen, _) + | Fable.List gen -> check gen + | Fable.Tuple(gens, _) -> List.exists check gens | _ -> false - if isSelfReference then - // Use string forward reference for self-referencing types - Expression.stringConstant name + check propType + + // Serialize an annotation expression to its Python source form so it can be emitted as a + // string forward reference (e.g. `Array[Example]` -> "Array[Example]"). + let rec annotationToString (expr: Expression) = + match expr with + | Expression.Name { Id = Identifier id } -> id + | Expression.Attribute { + Value = value + Attr = Identifier attr + } -> $"{annotationToString value}.{attr}" + | Expression.Subscript { + Value = value + Slice = slice + } -> $"{annotationToString value}[{annotationToString slice}]" + | Expression.Tuple { Elements = elements } -> elements |> List.map annotationToString |> String.concat ", " + | Expression.List(elements, _) -> + let inner = elements |> List.map annotationToString |> String.concat ", " + $"[{inner}]" + | Expression.BinOp { + Left = left + Right = right + Operator = BitOr + } -> $"{annotationToString left} | {annotationToString right}" + | Expression.Constant(StringLiteral s, _) -> s + | Expression.Constant(NoneLiteral, _) -> "None" + | _ -> "Any" + + let typeAnnotation = + if referencesEnclosingEntity propType then + // Render with the type alias (not the `_Example` base name) so the forward + // reference resolves to the public type at module scope. + let ctx = { ctx with EnclosingUnionBaseClass = None } + let ta, _ = Annotation.typeAnnotation com ctx None propType + Expression.stringConstant (annotationToString ta) else // Use normal type annotation let ta, _ = Annotation.typeAnnotation com ctx None propType diff --git a/tests/Python/TestUnionType.fs b/tests/Python/TestUnionType.fs index 2336d44c52..b5c33a4750 100644 --- a/tests/Python/TestUnionType.fs +++ b/tests/Python/TestUnionType.fs @@ -235,3 +235,22 @@ let ``test Union cases with no fields are physically equal`` () = obj.ReferenceEquals(MyUnion.Case0, MyUnion.Case0) |> equal true obj.ReferenceEquals(MyUnion.Case1 "a", MyUnion.Case1 "a") |> equal false obj.ReferenceEquals(T1, T1) |> equal true + +// See https://github.com/fable-compiler/Fable/issues/4736 +// A static member whose type references the enclosing union (here `Example[]`) used to emit a +// class-body `StaticLazyProperty[Array[_Example]]` subscript that evaluated `_Example` before the +// class name was bound, raising `NameError` at import time. +[] +[] +type Example = + | First + | Second + | Other of string + + static member All = [| Example.First; Example.Second |] + +[] +let ``test Static union member returning the union type works`` () = + Example.All |> Array.length |> equal 2 + Example.All.[0] |> equal Example.First + Example.All.[1] |> equal Example.Second From 5af3c96110124db590f8f4e5dadf41600f9cae91 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 9 Jul 2026 10:31:45 +0200 Subject: [PATCH 2/4] fix(python): detect self-referencing static union members in any type shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect a reference to the enclosing entity structurally via `Type.Generics` instead of a hand-rolled match, so function types (`unit -> Example`), delegates, nullables and anonymous records are covered too — the narrower match missed them and emitted a class-body subscript that raised `NameError` at import. Render the forward-reference string with the base-class name (`_Example`) rather than the alias (`Example`): both resolve at module scope, but the generated value is typed against the base class, so the base name keeps the annotation and value consistent for the type checker (avoids a Pyright error on function-typed members). Extend the regression test to cover array, option, nested list/array, tuple and function-type self-references. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Python/Fable2Python.Transforms.fs | 46 ++++++++++--------- tests/Python/TestUnionType.fs | 24 ++++++++-- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index ca4852bd25..4d6be1f6ad 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -4762,28 +4762,28 @@ let transformStaticProperty let staticPropertyClass = libValue com ctx "util" className // The `StaticProperty[T]` subscript is a value expression evaluated in the class body, so - // any name it references must already be bound. A self-referencing type breaks this: for - // unions the descriptor lives on the base class (`_Example`) and the type alias (`Example`) - // is only defined afterwards, so a bare name raises `NameError` at class-body evaluation. - // Detect a reference to the enclosing entity anywhere in the type (including nested inside - // `Array`, `Example option`, tuples, etc.) and emit the whole annotation as a - // string forward reference, which the type checker still resolves at module scope. + // the whole annotation must evaluate without error. A type referencing the enclosing entity + // breaks this: for unions the descriptor lives on the base class (`_Example`) and the type + // alias (`Example`) is only defined afterwards, so a bare name raises `NameError`. Quoting + // just that name is not enough either — an operator like the `|` in `Example | None` still + // runs eagerly and fails on a string operand. So when the type references the enclosing + // entity anywhere, emit the whole annotation as a single string forward reference, which + // never evaluates at runtime yet the type checker still resolves at module scope. let referencesEnclosingEntity (propType: Fable.Type) = + // Walk the type structurally via `Type.Generics`, which enumerates the generic args of + // every case (Array, Option, List, Tuple, Lambda/Delegate, Nullable, anonymous records, + // nested declared types), so no shape is missed as new cases are added. let rec check (t: Fable.Type) = - match t with - | Fable.DeclaredType(entRef, genArgs) -> - let e = com.GetEntity(entRef) - e.DisplayName = name || e.FullName = ent.FullName || List.exists check genArgs - | Fable.Option(gen, _) - | Fable.Array(gen, _) - | Fable.List gen -> check gen - | Fable.Tuple(gens, _) -> List.exists check gens - | _ -> false + (match t with + | Fable.DeclaredType(entRef, _) -> (com.GetEntity entRef).FullName = ent.FullName + | _ -> false) + || List.exists check t.Generics check propType - // Serialize an annotation expression to its Python source form so it can be emitted as a - // string forward reference (e.g. `Array[Example]` -> "Array[Example]"). + // Serialize an annotation expression to its Python source form for use as a forward + // reference (e.g. `Array[Example] | None` -> "Array[Example] | None"). Only the shapes + // `Annotation.typeAnnotation` can produce need handling. let rec annotationToString (expr: Expression) = match expr with | Expression.Name { Id = Identifier id } -> id @@ -4810,13 +4810,17 @@ let transformStaticProperty let typeAnnotation = if referencesEnclosingEntity propType then - // Render with the type alias (not the `_Example` base name) so the forward - // reference resolves to the public type at module scope. - let ctx = { ctx with EnclosingUnionBaseClass = None } + // Render the enclosing union with its base-class name (`_Example`, not the alias + // `Example`): both resolve at module scope inside the forward-reference string, but + // the generated *value* is typed against the base class (e.g. a `unit -> Example` + // factory produces `() -> _Example`), so the base name keeps the annotation and the + // value consistent for the type checker. `typeAnnotation` still registers the + // imports the string references (e.g. `Array`) as a side effect. + let ctx = { ctx with EnclosingUnionBaseClass = Some ent.DisplayName } + let ta, _ = Annotation.typeAnnotation com ctx None propType Expression.stringConstant (annotationToString ta) else - // Use normal type annotation let ta, _ = Annotation.typeAnnotation com ctx None propType ta diff --git a/tests/Python/TestUnionType.fs b/tests/Python/TestUnionType.fs index b5c33a4750..f9b3d3f7bc 100644 --- a/tests/Python/TestUnionType.fs +++ b/tests/Python/TestUnionType.fs @@ -237,9 +237,11 @@ let ``test Union cases with no fields are physically equal`` () = obj.ReferenceEquals(T1, T1) |> equal true // See https://github.com/fable-compiler/Fable/issues/4736 -// A static member whose type references the enclosing union (here `Example[]`) used to emit a -// class-body `StaticLazyProperty[Array[_Example]]` subscript that evaluated `_Example` before the -// class name was bound, raising `NameError` at import time. +// A static member whose type references the enclosing union used to emit a class-body +// `StaticLazyProperty[...]` subscript that evaluated the union type before its name was bound, +// raising `NameError` at import time. The reference can be nested in any type shape, so each +// member below exercises a different one: array, option (rendered with `|`), nested list/array, +// tuple, and a function type (`unit -> Example`). [] [] type Example = @@ -248,9 +250,25 @@ type Example = | Other of string static member All = [| Example.First; Example.Second |] + static member Head = Some Example.First + static member Nested = [ [| Example.First |] ] + static member Pair = (Example.First, Example.Second) + static member Factory: unit -> Example = fun () -> Example.First [] let ``test Static union member returning the union type works`` () = Example.All |> Array.length |> equal 2 Example.All.[0] |> equal Example.First Example.All.[1] |> equal Example.Second + +[] +let ``test Static union member with nested self-reference works`` () = + // Option (`_Example | None`): a bare quoted leaf would leave the `|` to run eagerly and fail. + Example.Head |> equal (Some Example.First) + // Nested list of array (`FSharpList[Array[_Example]]`). + Example.Nested |> List.head |> Array.head |> equal Example.First + // Tuple (`tuple[_Example, _Example]`). + Example.Pair |> fst |> equal Example.First + Example.Pair |> snd |> equal Example.Second + // Function type (`Callable[[], _Example]`) — missed by the original narrower detection. + Example.Factory() |> equal Example.First From 8dd010d317407d66c8d3a14eb9c5cb7b50f6e166 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 9 Jul 2026 10:51:15 +0200 Subject: [PATCH 3/4] chore(python): condense self-reference forward-ref comment Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Python/Fable2Python.Transforms.fs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index 4d6be1f6ad..d7768292ff 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -4761,14 +4761,11 @@ let transformStaticProperty let staticPropertyClass = libValue com ctx "util" className - // The `StaticProperty[T]` subscript is a value expression evaluated in the class body, so - // the whole annotation must evaluate without error. A type referencing the enclosing entity - // breaks this: for unions the descriptor lives on the base class (`_Example`) and the type - // alias (`Example`) is only defined afterwards, so a bare name raises `NameError`. Quoting - // just that name is not enough either — an operator like the `|` in `Example | None` still - // runs eagerly and fails on a string operand. So when the type references the enclosing - // entity anywhere, emit the whole annotation as a single string forward reference, which - // never evaluates at runtime yet the type checker still resolves at module scope. + // `StaticProperty[T]` is a value expression evaluated in the class body, so a type + // referencing the enclosing entity (not yet bound there) raises `NameError`. Quoting just + // the name is not enough — an operator like the `|` in `Example | None` still runs eagerly + // and fails on a string operand. So emit the whole annotation as one string forward + // reference: it never evaluates at runtime, yet the type checker resolves it at module scope. let referencesEnclosingEntity (propType: Fable.Type) = // Walk the type structurally via `Type.Generics`, which enumerates the generic args of // every case (Array, Option, List, Tuple, Lambda/Delegate, Nullable, anonymous records, From 9fd12214b6d82b1eaf49e598ccb07d3de766e765 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Thu, 9 Jul 2026 11:10:38 +0200 Subject: [PATCH 4/4] refactor(python): move annotationToString to Fable2Python.Annotation Keeps the main transform file to Fable-AST-to-target-AST transformations and places the annotation serializer next to `typeAnnotation`, which produces the expressions it consumes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Python/Fable2Python.Annotation.fs | 27 +++++++++++++++++ .../Python/Fable2Python.Transforms.fs | 29 +------------------ 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Annotation.fs b/src/Fable.Transforms/Python/Fable2Python.Annotation.fs index 17a73dacc3..3650625fde 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Annotation.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Annotation.fs @@ -404,6 +404,33 @@ let resolveGenerics com ctx generics repeatedGenerics : Expression list * Statem |> List.map (typeAnnotation com ctx repeatedGenerics) |> Helpers.unzipArgs +/// Serialize an annotation expression to its Python source form for use as a string forward +/// reference (e.g. `Array[Example] | None` -> "Array[Example] | None"). Only the shapes +/// `typeAnnotation` can produce need handling. +let rec annotationToString (expr: Expression) = + match expr with + | Expression.Name { Id = Identifier id } -> id + | Expression.Attribute { + Value = value + Attr = Identifier attr + } -> $"{annotationToString value}.{attr}" + | Expression.Subscript { + Value = value + Slice = slice + } -> $"{annotationToString value}[{annotationToString slice}]" + | Expression.Tuple { Elements = elements } -> elements |> List.map annotationToString |> String.concat ", " + | Expression.List(elements, _) -> + let inner = elements |> List.map annotationToString |> String.concat ", " + $"[{inner}]" + | Expression.BinOp { + Left = left + Right = right + Operator = BitOr + } -> $"{annotationToString left} | {annotationToString right}" + | Expression.Constant(StringLiteral s, _) -> s + | Expression.Constant(NoneLiteral, _) -> "None" + | _ -> "Any" + let rec typeAnnotation (com: IPythonCompiler) ctx diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index d7768292ff..03c7cd96f8 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -4778,33 +4778,6 @@ let transformStaticProperty check propType - // Serialize an annotation expression to its Python source form for use as a forward - // reference (e.g. `Array[Example] | None` -> "Array[Example] | None"). Only the shapes - // `Annotation.typeAnnotation` can produce need handling. - let rec annotationToString (expr: Expression) = - match expr with - | Expression.Name { Id = Identifier id } -> id - | Expression.Attribute { - Value = value - Attr = Identifier attr - } -> $"{annotationToString value}.{attr}" - | Expression.Subscript { - Value = value - Slice = slice - } -> $"{annotationToString value}[{annotationToString slice}]" - | Expression.Tuple { Elements = elements } -> elements |> List.map annotationToString |> String.concat ", " - | Expression.List(elements, _) -> - let inner = elements |> List.map annotationToString |> String.concat ", " - $"[{inner}]" - | Expression.BinOp { - Left = left - Right = right - Operator = BitOr - } -> $"{annotationToString left} | {annotationToString right}" - | Expression.Constant(StringLiteral s, _) -> s - | Expression.Constant(NoneLiteral, _) -> "None" - | _ -> "Any" - let typeAnnotation = if referencesEnclosingEntity propType then // Render the enclosing union with its base-class name (`_Example`, not the alias @@ -4816,7 +4789,7 @@ let transformStaticProperty let ctx = { ctx with EnclosingUnionBaseClass = Some ent.DisplayName } let ta, _ = Annotation.typeAnnotation com ctx None propType - Expression.stringConstant (annotationToString ta) + Expression.stringConstant (Annotation.annotationToString ta) else let ta, _ = Annotation.typeAnnotation com ctx None propType ta