Skip to content

[TS] Emit interface declaration when encountering a ParamObject pattern #4007

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jan 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

* [TS] Include XML Doc comment on interface properties (by @Freymaurer)
* [TS] Generate `interface` type when using the "ParamObject" class pattern (by @MangelMaxime)

## 5.0.0-alpha.7 - 2025-01-23

### Fixed
Expand Down
5 changes: 5 additions & 0 deletions src/Fable.Compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

* [TS] Include XML Doc comment on interface properties (by @Freymaurer)
* [TS] Generate `interface` type when using the "ParamObject" class pattern (by @MangelMaxime)

## 5.0.0-alpha.7 - 2025-01-23

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion src/Fable.Transforms/BabelPrinter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ module PrinterExtensions =
printer.PrintFunction(Some id, parameters, body, typeParameters, returnType, loc, isDeclaration = true)

printer.PrintNewLine()
| InterfaceDeclaration(id, body, extends, typeParameters) ->
| InterfaceDeclaration(id, body, extends, typeParameters, _) ->
printer.PrintInterfaceDeclaration(id, body, extends, typeParameters)
| EnumDeclaration(name, cases, isConst) ->
if isConst then
Expand Down
67 changes: 63 additions & 4 deletions src/Fable.Transforms/FSharp2Fable.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,50 @@ module Helpers =
| None -> false
)

// When compiling to TypeScript, we want to captuer classes that use the
// [<Global>] attribute on the type and[<ParamObject>] on the constructors
// so we can transform it into an interface

/// <summary>
/// Check if the entity is decorated with the <code>Global</code> attribute
/// and all its constructors are decorated with <code>ParamObject</code> attribute.
///
/// This is used to identify classes that should be transformed into interfaces.
/// </summary>
/// <param name="entity"></param>
/// <returns>
/// <code>true</code> if the entity is a global type with all constructors as param objects,
/// <code>false</code> otherwise.
/// </returns>
let isParamObjectClassPattern (entity: Fable.Entity) =
let isGlobalType =
entity.Attributes |> Seq.exists (fun att -> att.Entity.FullName = Atts.global_)

let areAllConstructorsParamObject =
entity.MembersFunctionsAndValues
|> Seq.filter _.IsConstructor
|> Seq.forall (fun memb ->
// Empty constructors are considered valid as it allows to simplify unwraping
// complex Union types
//
// [<AllowNullLiteral>]
// [<Global>]
// type ClassWithUnion private () =
// [<ParamObjectAttribute; Emit("$0")>]
// new (stringOrNumber : string) = ClassWithUnion()
// [<ParamObjectAttribute; Emit("$0")>]
// new (stringOrNumber : int) = ClassWithUnion()
//
// Without this trick when we have a lot of U2, U3, etc. to map it is really difficult
// or verbose to craft the correct F# class. By using, an empty constructor we can
// "bypass" the F# type system.
memb.CurriedParameterGroups |> List.concat |> List.isEmpty
|| memb.Attributes
|> Seq.exists (fun att -> att.Entity.FullName = Atts.paramObject)
)

isGlobalType && areAllConstructorsParamObject

let tryPickAttrib attFullNames (attributes: FSharpAttribute seq) =
let attFullNames = Map attFullNames

Expand Down Expand Up @@ -2002,10 +2046,25 @@ module Util =

let tryGlobalOrImportedAttributes (com: Compiler) (entRef: Fable.EntityRef) (attributes: Fable.Attribute seq) =
let globalRef customName =
defaultArg customName entRef.DisplayName
|> makeTypedIdent Fable.Any
|> Fable.IdentExpr
|> Some
let name =
// Custom name has precedence
match customName with
| Some name -> name
| None ->
let entity = com.GetEntity(entRef)

// If we are generating TypeScript, and the entity is an object class pattern
// we need to use the compiled name, replacing '`' with '$' to mimic
// how Fable generates the compiled name for generic types
// I was not able to find where this is done in Fable, so I am doing it manually here
if com.Options.Language = TypeScript && isParamObjectClassPattern entity then
entity.CompiledName.Replace("`", "$")
// Otherwise, we use the display name as `Global` is often used to describe external API
// and we want to keep the original name
else
entRef.DisplayName

name |> makeTypedIdent Fable.Any |> Fable.IdentExpr |> Some
Comment on lines +2049 to +2067
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MangelMaxime Possibly a difference between using a type's CompiledName vs DisplayName

@ncave You were right this was because for Global we always used the DisplayName as in general this is used to map to native API.


match attributes with
| _ when entRef.FullName.StartsWith("Fable.Core.JS.", StringComparison.Ordinal) -> globalRef None
Expand Down
3 changes: 2 additions & 1 deletion src/Fable.Transforms/FSharp2Fable.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2044,7 +2044,8 @@ let rec private transformDeclarations (com: FableCompiler) ctx fsDecls =

if
(isErasedOrStringEnumEntity ent && Compiler.Language <> TypeScript)
|| isGlobalOrImportedEntity ent
|| (isGlobalOrImportedEntity ent
&& (not (Compiler.Language = TypeScript && isParamObjectClassPattern ent)))
then
[]
else
Expand Down
142 changes: 116 additions & 26 deletions src/Fable.Transforms/Fable2Babel.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3782,6 +3782,89 @@ module Util =

declareType com ctx ent entName args body baseExpr classMembers

let transformParamObjectClassPatternToInterface
(com: IBabelCompiler)
ctx
(classDecl: Fable.ClassDecl)
(ent: Fable.Entity)
=

let constructors =
ent.MembersFunctionsAndValues
|> Seq.filter _.IsConstructor
|> Seq.choose (fun constructor ->
let parameters = List.concat constructor.CurriedParameterGroups

if parameters.Length = 0 then
None
else

parameters
|> List.mapi (fun index arg ->
let name = defaultArg arg.Name $"arg{index}"

/// Try to find getter/setter in f# syntax for POJOs. If found propagate its xml doc to interface.
let tryXmlDoc =
ent.MembersFunctionsAndValues
|> Seq.tryFind (fun s -> s.DisplayName = name)
|> Option.bind (fun tgs -> tgs.XmlDoc)

let typeAnnotation =
if arg.IsOptional then
unwrapOptionalType arg.Type
else
arg.Type
|> FableTransforms.uncurryType
|> makeTypeAnnotation com ctx

AbstractMember.abstractProperty (
name |> Identifier.identifier |> Expression.Identifier,
typeAnnotation,
isOptional = arg.IsOptional,
?doc = tryXmlDoc
)
)
|> Array.ofSeq
|> Some
)
|> Seq.toList


let typeParameters =
ent.GenericParameters
|> List.map (fun g -> Fable.GenericParam(g.Name, g.IsMeasure, g.Constraints))
|> makeTypeParamDecl com ctx

match constructors with
| [] ->
addError
com
[]
None
"Unable to find a valid constructor for generating interface via ParamObject, please make sure the constructor has at least one parameter."

[]
| members :: [] ->
Declaration.interfaceDeclaration (
Identifier.identifier classDecl.Name,
members,
[||],
typeParameters,
?doc = classDecl.XmlDoc
)
|> asModuleDeclaration ent.IsPublic
|> List.singleton
| _ ->
let typ =
List.map ObjectTypeAnnotation constructors
|> Array.ofList
|> UnionTypeAnnotation

TypeAliasDeclaration(classDecl.Name, typeParameters, typ)
|> asModuleDeclaration ent.IsPublic
|> List.singleton


let transformClassWithPrimaryConstructor
(com: IBabelCompiler)
ctx
Expand Down Expand Up @@ -4124,33 +4207,40 @@ module Util =
else
[]
| ent ->
let classMembers =
decl.AttachedMembers
|> List.toArray
|> Array.collect (fun memb ->
withCurrentScope ctx memb.UsedNames
<| fun ctx ->
memb.ImplementedSignatureRef
|> Option.bind (com.TryGetMember)
|> Option.orElseWith (fun () -> com.TryGetMember(memb.MemberRef))
|> function
| None -> [||]
| Some info ->
if not memb.IsMangled && (info.IsGetter || info.IsSetter) then
transformAttachedProperty com ctx ent info memb
else
transformAttachedMethod com ctx ent info memb
)
if
Compiler.Language = TypeScript
&& FSharp2Fable.Helpers.isParamObjectClassPattern ent
then
transformParamObjectClassPatternToInterface com ctx decl ent
else

match decl.Constructor with
| Some cons ->
withCurrentScope ctx cons.UsedNames
<| fun ctx -> transformClassWithPrimaryConstructor com ctx ent decl classMembers cons
| None ->
if ent.IsFSharpUnion then
transformUnion com ctx ent decl.Name classMembers
else
transformClassWithCompilerGeneratedConstructor com ctx ent decl.Name classMembers
let classMembers =
decl.AttachedMembers
|> List.toArray
|> Array.collect (fun memb ->
withCurrentScope ctx memb.UsedNames
<| fun ctx ->
memb.ImplementedSignatureRef
|> Option.bind (com.TryGetMember)
|> Option.orElseWith (fun () -> com.TryGetMember(memb.MemberRef))
|> function
| None -> [||]
| Some info ->
if not memb.IsMangled && (info.IsGetter || info.IsSetter) then
transformAttachedProperty com ctx ent info memb
else
transformAttachedMethod com ctx ent info memb
)

match decl.Constructor with
| Some cons ->
withCurrentScope ctx cons.UsedNames
<| fun ctx -> transformClassWithPrimaryConstructor com ctx ent decl classMembers cons
| None ->
if ent.IsFSharpUnion then
transformUnion com ctx ent decl.Name classMembers
else
transformClassWithCompilerGeneratedConstructor com ctx ent decl.Name classMembers

let transformImports (imports: Import seq) : ModuleDeclaration list =
let statefulImports = ResizeArray()
Expand Down
10 changes: 6 additions & 4 deletions src/Fable.Transforms/Global/Babel.fs
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,16 @@ type Declaration =
id: Identifier *
members: AbstractMember array *
extends: TypeAnnotation array *
typeParameters: TypeParameter array
typeParameters: TypeParameter array *
doc: string option
| EnumDeclaration of name: string * cases: (string * Expression) array * isConst: bool
| TypeAliasDeclaration of name: string * typeParameters: TypeParameter array * alias: TypeAnnotation

member this.JsDoc =
match this with
| ClassDeclaration(_, _, _, _, _, _, doc)
| FunctionDeclaration(_, _, _, _, _, _, doc) -> doc
| FunctionDeclaration(_, _, _, _, _, _, doc)
| InterfaceDeclaration(_, _, _, _, doc) -> doc
| _ -> None

/// A module import or export declaration.
Expand Down Expand Up @@ -779,8 +781,8 @@ module Helpers =
static member classDeclaration(body, ?id, ?superClass, ?typeParameters, ?implements, ?loc, ?doc) =
ClassDeclaration(body, id, superClass, defaultArg implements [||], defaultArg typeParameters [||], loc, doc)

static member interfaceDeclaration(id, body, ?extends, ?typeParameters) : Declaration = // ?mixins_,
InterfaceDeclaration(id, body, defaultArg extends [||], defaultArg typeParameters [||])
static member interfaceDeclaration(id, body, ?extends, ?typeParameters, ?doc) : Declaration = // ?mixins_,
InterfaceDeclaration(id, body, defaultArg extends [||], defaultArg typeParameters [||], doc)

static member enumDeclaration(name, cases, ?isConst) =
EnumDeclaration(name, cases, defaultArg isConst false)
Expand Down
Loading
Loading