Skip to content

feat(js/ts): add InlineInterface for TypeScriptTaggedUnion cases - #5113

Open
shayanhabibi wants to merge 2 commits into
fable-compiler:mainfrom
shayanhabibi:feat/ts-tagged-union-inline-interface
Open

shayanhabibi wants to merge 2 commits into
fable-compiler:mainfrom
shayanhabibi:feat/ts-tagged-union-inline-interface

Conversation

@shayanhabibi

Copy link
Copy Markdown
Contributor

Implements the compiler part of #5078.

What

Since #3844 (4.19.1), a TypeScriptTaggedUnion case whose single field is an interface compiles to { type, Item }, so TypeScript code reads command.Item.fromIndex. The docs still describe the earlier shape, Take & { type: "take" }. This PR adds an opt-in attribute, Fable.Core.InlineInterfaceAttribute, that produces the documented shape. Default output is unchanged.

[<TypeScriptTaggedUnion("type")>]
type Command =
    | [<InlineInterface>] Take of Take   // this case only
    | Edit of Edit
    | Save

[<TypeScriptTaggedUnion("type"); InlineInterface>]
type Command2 =                          // every case with fields
    | Take of Take
    | Edit of Edit
    | Save
export type Command =
    | Take & { type: "take" }
    | { type: "edit", Item: Edit }
    | { type: "save" }

export function makeTake(t: Take): Command {
    return Object.assign(Object.create(Object.getPrototypeOf(t)), t, {
        type: "take",
    });
}

export function takeOf(c: Command): Option<Take> {
    if (c.type === "take") {
        return c as Take;
    }
    else {
        return undefined;
    }
}

How

  • Fable.Core: a new InlineInterfaceAttribute(), valid on a case or on the union type. This is purely an addition.
  • Fable.AST: UnionCase gains Attributes: Attribute seq, as Entity, MemberFunctionOrValue and Parameter already have. Fable2Babel builds the type annotation from Fable.UnionCase, so without this it could only see the attribute on the type, not on a case. Adding an abstract member breaks any code outside Fable that implements Fable.UnionCase. Code that only reads it is unaffected, and I'm not aware of any implementations outside FsUnionCase.
  • Transforms.Util.tryInlinedInterface: one check, shared by FSharp2Fable and Fable2Babel, that decides whether a case is inlined. A case qualifies when the attribute is on it or on its type and the case has exactly one field, whose type is an interface. On the type, cases without fields are skipped.
  • Type: the arm is Field & { tag: "..." }.
  • Reading the field: the union value is the interface, so Take t binds t to the value itself (a TypeCast).
  • Construction: Object.assign(Object.create(Object.getPrototypeOf(t)), t, { type: "take" }). The value is evaluated once, bound to a temporary when it could have side effects.
  • Matching and IsTake: unchanged; they already test the tag.
  • Errors: an inlined case that doesn't have that shape ([<InlineInterface>] Count of int, or a fieldless case carrying the attribute itself) reports an error at construction and field reads. A primitive can't carry a tag, which was Missing discriminator field when using TypeScriptTaggedUnion #3837.
  • The commented-out pre-Fixes #3837 #3844 code at the three sites is replaced, and the unused FsUnionCase.HasNamedFields comment is removed.

Construction: why a prototype-preserving copy

#5078 listed three options and asked for input. None came, so here is the reasoning:

  • Trust the value (return t as is) is the pre-Fixes #3837 #3844 behaviour, and leaves values built in F# without a tag (Missing discriminator field when using TypeScriptTaggedUnion #3837).
  • Mutate (Object.assign(t, { type })) writes to an object the caller owns. Constructing a union case shouldn't change its argument.
  • Spread ({ ...t, type }) leaves the caller's object alone. But it copies own properties only, so an F# class instance implementing the interface loses its members, which live on the prototype.

The copy here takes the prototype along. For a plain object it gives the same result as a spread. For a class instance it keeps the members working. The caller's object stays unchanged, and only object identity differs. I'm happy to switch to a plain spread if that's preferred.

Notes

Tests

  • tests/Js/Main/JsInteropTests.fs has four new tests:
    • the case form: construction (the tag is set, there is no Item, the caller's object is unchanged), matching, a raw JS object, IsTake, and a non-inlined case in the same union;
    • the type form;
    • a class instance keeps its members;
    • the value is evaluated once.
  • tests/Integration/Compiler/CompilerMessagesTests.fs has two new tests: a case with an int field reports an error, and a valid union compiles with no warnings. All 131 compiler tests pass.
  • ./build.sh test javascript and test typescript: everything passes except the two DateTimeOffset.ToString('d'/'D') tests, which also fail on main in my time zone (UTC+8).
  • ./build.sh test integration: 41/42. The failure is pathMap, which expects / and gets \ on Windows; it's unrelated to this PR.
  • TypeScript output passes tsc --strict, both from source and through a DLL. JsInteropTests.fs isn't part of the TypeScript test project, so I checked this separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LySb2Kjdc8qoY9Qoikr2u5

shayanhabibi and others added 2 commits October 20, 2026 15:53
Since fable-compiler#3844, a TypeScriptTaggedUnion case whose single field is an
interface compiles to `{ tag, Item }`, so TypeScript code reads
`command.Item.fromIndex` rather than the documented `Take & { type: "take" }`.

[<InlineInterface>] on a case, or on the union type for every case with
fields, compiles the case as its interface with the tag added:

- the case type is `Take & { type: "take" }`;
- reading the field returns the union value;
- construction copies the value with its prototype and sets the tag on
  the copy, so the caller's object is unchanged and class members keep
  working; the value is evaluated once;
- matching and `IsTake` are unchanged.

A case with InlineInterface must have exactly one field, whose type is an
interface; otherwise construction and field reads report an error.

Fable.AST: `UnionCase` gains `Attributes`, so Fable2Babel can read a
case-level attribute when building the type annotation.

Refs fable-compiler#5078: this is the compiler half; the docs page still needs updating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LySb2Kjdc8qoY9Qoikr2u5
The standalone compiler compiles against the committed metadata
assembly, which did not yet contain InlineInterfaceAttribute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LySb2Kjdc8qoY9Qoikr2u5
shayanhabibi added a commit to shayanhabibi/Fable that referenced this pull request Oct 20, 2026
Adds InlineInterface (fable-compiler#5113) next to CaseTest. The
fable-metadata Fable.Core.dll is rebuilt from the merged Fable.Core so it
carries both attributes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LySb2Kjdc8qoY9Qoikr2u5

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant