From 96c5730d9c84f89ded78ba90367669c9358f3fe9 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Tue, 7 Jul 2026 09:23:25 +0200 Subject: [PATCH] perf(python): emit union field reads via `narrow` helper instead of `typing.cast` Union case field reads are compiled as `cast(Case, x).field`, which required disabling `reportUnnecessaryCast` globally (single-case unions and already-narrowed values make some of those casts redundant from Pyright's view). Move the "cast" into a small library helper `narrow(Case, x)` in union.py: a typed identity function (single runtime call, same cost as the previous inline cast). This keeps generated code free of `typing.cast`, so `reportUnnecessaryCast` can stay enabled for the rest of the generated Python. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyrightconfig.json | 2 +- .../Python/Fable2Python.Transforms.fs | 11 +++++----- src/fable-library-py/fable_library/union.py | 20 ++++++++++++++++++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/pyrightconfig.json b/pyrightconfig.json index df24032e47..6c0ea2cb63 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -9,9 +9,9 @@ "reportConstantRedefinition": false, // Fable preserves casing for variables starting with uppercase "reportUnnecessaryIsInstance": false, // Not a problem in generated code (look into this later) "reportUnnecessaryComparison": false, // Generated tests do this on purpose - "reportUnnecessaryCast": false, // Generated code for union case field need addional cast otherwise Pyrigh cannot infer the resolved typed "reportImportCycles": true, "reportMissingImports": true, + "reportUnnecessaryCast": true, "reportDuplicateImport": true, "reportOverlappingOverload": true, "reportInconsistentConstructor": true, diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index f5d4bce812..8363698ca3 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -1466,12 +1466,13 @@ let transformGet (com: IPythonCompiler) ctx range typ (fableExpr: Fable.Expr) ki let field = uci.UnionCaseFields |> List.item i.FieldIndex let fieldName = field.Name |> Naming.toFieldSnakeCase |> Helpers.clean // `fableExpr` is statically typed as the union base, which doesn't declare this - // field, so tell Pyright which case class we're reading from (cast is a no-op - // at runtime). See reportUnnecessaryCast in pyrightconfig.json. + // field, so tell Pyright which case class we're reading from via the library's + // `narrow` helper (a no-op at runtime). Keeping the "cast" inside `narrow` + // leaves generated code cast-free and preserves reportUnnecessaryCast. let caseRef = getUnionCaseRef com ctx i.Entity ent uci - let cast = com.GetImportExpr(ctx, "typing", "cast") - let castedExpr = Expression.call (cast, [ caseRef; baseExpr ]) - let! finalExpr = getExpr com ctx range castedExpr (Expression.stringConstant fieldName) + let narrow = libValue com ctx "union" "narrow" + let narrowedExpr = Expression.call (narrow, [ caseRef; baseExpr ]) + let! finalExpr = getExpr com ctx range narrowedExpr (Expression.stringConstant fieldName) return finalExpr } diff --git a/src/fable-library-py/fable_library/union.py b/src/fable-library-py/fable_library/union.py index 192f264582..db30dc1517 100644 --- a/src/fable-library-py/fable_library/union.py +++ b/src/fable-library-py/fable_library/union.py @@ -101,6 +101,24 @@ def CompareTo(self, other: Any) -> int: return -1 if self.tag < other.tag else 1 +def narrow[T](case: type[T], value: object) -> T: + """Narrow a union value to one of its case classes for the type checker. + + F# discriminated unions compile to a base ``Union`` class plus one case + class per case (e.g. ``Tree`` -> ``Tree_Leaf | Tree_Node``). Reading a + case-specific field such as ``x.left_`` requires the value to be typed as + that case class, but after a ``match`` on ``tag`` it is still statically + typed as the union. ``narrow(Tree_Node, x)`` re-types ``x`` so the field + read resolves, playing the same role as ``typing.cast`` but scoped to + unions. Keeping it in the library (instead of emitting ``cast`` inline) + lets generated code stay ``cast``-free and preserves ``reportUnnecessaryCast``. + + ``case`` is only used by the type checker to bind ``T``; this is an identity + function at runtime. + """ + return value # pyright: ignore[reportReturnType] + + @dataclass_transform() def tagged_union(tag: int): """Decorator for union case classes. @@ -135,4 +153,4 @@ def fields(self) -> Array[Any]: return decorator -__all__ = ["Union", "tagged_union"] +__all__ = ["Union", "narrow", "tagged_union"]