Skip to content

[JS/TS] Add F# quotation support#4474

Merged
MangelMaxime merged 9 commits intofable-compiler:mainfrom
OnurGumus:quotation-support
Apr 7, 2026
Merged

[JS/TS] Add F# quotation support#4474
MangelMaxime merged 9 commits intofable-compiler:mainfrom
OnurGumus:quotation-support

Conversation

@OnurGumus
Copy link
Copy Markdown
Contributor

@OnurGumus OnurGumus commented Apr 2, 2026

Summary

Builds on the shared quotation infrastructure from #4398 to enable F# code quotations (<@ expr @> and <@@ expr @@>) for the JavaScript/TypeScript target.

  • Replace the "not yet supported" error stubs in Fable2Babel.fs with actual Quote handlers that call the shared QuotationEmitter
  • Add quotation.ts JS/TS runtime library matching the Python/Beam API (constructors, pattern matchers, evaluator, toString, getFreeVars, substitute)
  • Wire up Replacements.fs via the shared Quotations.tryQuotationCall for pattern matching (Patterns.|Value|_|, etc.), evaluation (LeafExpressionConverter.EvaluateQuotation), and Var accessors
  • Add splice (%) support for composing quotations
  • JSON serialization: all Expr classes have toJSON() producing Thoth.Json Auto-compatible ["Tag", ...fields] arrays, plus exprFromJSON() deserializer
  • Fable.Core.Quotations: shared QuotExpr / QuotVar DU types for .NET-side deserialization
  • Add comprehensive tests including JSON round-trip

Deserializing on .NET

The JSON format uses ["Tag", ...fields] arrays, matching the Thoth.Json Auto DU encoding. On .NET, deserialize with FSharp.SystemTextJson's ThothLike preset:

open System.Text.Json
open System.Text.Json.Serialization
open Fable.Core.Quotations

let options = JsonSerializerOptions()
options.Converters.Add(JsonFSharpConverter(JsonUnionEncoding.ThothLike))
let expr = JsonSerializer.Deserialize<QuotExpr>(json, options)

End-to-end example — using a Fable quotation as a server-side filter (verified working):

open System.Text.Json
open System.Text.Json.Serialization
open Fable.Core.Quotations

// JSON from Fable client: <@ fun (x: Person) -> x.Name = "foo" @>
let json = """["Lambda",{"Name":"x","Type":"obj","IsMutable":false},["Call",null,"op_Equality",[["FieldGet",["Var",{"Name":"x","Type":"obj","IsMutable":false}],"Name"],["Value","foo","string"]]]]"""

// 1. Deserialize
let options = JsonSerializerOptions()
options.Converters.Add(JsonFSharpConverter(JsonUnionEncoding.ThothLike))
let expr = JsonSerializer.Deserialize<QuotExpr>(json, options)

// 2. Helper to extract literal values (Value case contains obj + typeName)
let unboxValue (value: obj) (typeName: string) : obj =
    match value with
    | :? JsonElement as el ->
        match typeName with
        | "int32" -> el.GetInt32() |> box
        | "float64" -> el.GetDouble() |> box
        | "string" -> el.GetString() |> box
        | "bool" -> el.GetBoolean() |> box
        | _ -> value
    | _ -> value

// 3. Simple evaluator
type Person = { Name: string; Age: int }

let rec eval (env: Map<string, obj>) (expr: QuotExpr) : obj =
    match expr with
    | QuotExpr.Value(v, t) -> unboxValue v t
    | QuotExpr.Var(v) -> env[v.Name]
    | QuotExpr.Lambda(v, body) ->
        box (fun (arg: obj) -> eval (Map.add v.Name arg env) body)
    | QuotExpr.Call(_, "op_Equality", args) ->
        let l = eval env args[0]
        let r = eval env args[1]
        box (l = r)
    | QuotExpr.FieldGet(e, fieldName) ->
        let target = eval env e
        target.GetType().GetProperty(fieldName).GetValue(target)
    | _ -> failwith $"Unsupported node"

// 4. Use as a filter predicate
let predicate = eval Map.empty expr :?> (obj -> obj)
let people = [ { Name = "foo"; Age = 30 }; { Name = "bar"; Age = 25 } ]
let filtered = people |> List.filter (fun p -> predicate (box p) :?> bool)
// => [ { Name = "foo"; Age = 30 } ]

Changed files

File Change
src/Fable.Transforms/Fable2Babel.fs Replace Quote error stubs with QuotationEmitter.emitQuotedExpr handlers
src/Fable.Transforms/Replacements.fs Add splice support + shared Quotations.tryQuotationCall "quotation"
src/fable-library-ts/quotation.ts New — JS/TS runtime with JSON serialization/deserialization
src/Fable.Core/Fable.Core.Quotations.fs New — Shared QuotExpr/QuotVar types for .NET deserialization
tests/Js/Main/QuotationTests.fs New — Tests including JSON round-trip

Test plan

  • All existing JS tests pass (2,859 passing, 0 failing)
  • All .NET tests pass (2,687 passing)
  • Pattern matching tests (Value, Lambda, Let, IfThenElse, Application, NewTuple, Sequential, Var)
  • Evaluation tests (LeafExpressionConverter.EvaluateQuotation for arithmetic, let bindings, lambdas, tuples, comparisons)
  • Instance method tests (GetFreeVars)
  • JSON serialization round-trip tests
  • Untyped quotation (<@@ @@>) works
  • .NET deserialization + evaluation verified end-to-end with FSharp.SystemTextJson ThothLike

@dbrattli
Copy link
Copy Markdown
Collaborator

dbrattli commented Apr 2, 2026

Nice! We should coordinate / align this with #4474

dbrattli added a commit that referenced this pull request Apr 4, 2026
Rename to match #4474 conventions so it can rebase cleanly:
- mkVarExpr -> mkVar (Expr.Var), mkVar -> mkQuotVar (Var constructor)
- mkApp -> mkApplication
- isNewUnion -> isNewUnionCase

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dbrattli added a commit that referenced this pull request Apr 4, 2026
Use "quotation" as the canonical module name:
- Python: quotation.py (renamed from fable_quotation.py)
- Beam: fable_quotation.erl (fable_ prefix added by getLibPath)
- JS (#4474): Quotation.ts (natural mapping)

Revert the Beam getLibPath workaround as it's no longer needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@dbrattli
Copy link
Copy Markdown
Collaborator

dbrattli commented Apr 4, 2026

I have fixed #4398 so that the common code will work for JS/TS as well. Thus can be reused by this PR, so most changes to Replacements.fs will not be needed. @MangelMaxime @ncave. If we are good with adding Quotations then I can merge my PR? Then this PR will be much simpler on top of that.

@OnurGumus
Copy link
Copy Markdown
Contributor Author

@dbrattli by all means that's fine by me. probably better as my pr only targets js/ts as of now.

@dbrattli
Copy link
Copy Markdown
Collaborator

dbrattli commented Apr 4, 2026

@OnurGumus Ok, let us just check with @MangelMaxime that we are ok with the Fable.AST change at this point since it may break extensions.

@dbrattli
Copy link
Copy Markdown
Collaborator

dbrattli commented Apr 6, 2026

@OnurGumus We are good to go. Please rebase PR on main now, then we get support for JS/TS/Beam and Python for this feature

…d evaluation

Builds on the shared quotation infrastructure from fable-compiler#4398 to enable
quotation support for the JavaScript/TypeScript target. Replaces the
"not yet supported" error stubs in Fable2Babel.fs with actual Quote
handlers, adds the JS/TS runtime library (quotation.ts) matching the
Python/Beam API, wires up Replacements.fs via the shared
Quotations.tryQuotationCall, and adds comprehensive tests.
@OnurGumus OnurGumus force-pushed the quotation-support branch from 7264c80 to e662d62 Compare April 6, 2026 15:14
Replace constructor parameter properties (readonly params) with
explicit field declarations to comply with erasableSyntaxOnly.
- Let binding: check name starts with "x" (FSharp2Fable may add suffix)
- NewTuple: use List.length instead of .Length (isNewTuple returns array)
@OnurGumus
Copy link
Copy Markdown
Contributor Author

@dbrattli done

@dbrattli
Copy link
Copy Markdown
Collaborator

dbrattli commented Apr 6, 2026

@OnurGumus Please update PR description. What happened to JSON serialization? Will you add it back in a later PR?

@OnurGumus OnurGumus changed the title [JS/TS] Add F# quotation support with serializable QuotExpr AST [JS/TS] Add F# quotation support Apr 7, 2026
@OnurGumus
Copy link
Copy Markdown
Contributor Author

I've changed. Sorry, in my day to day work, I never use PRs but use gerrit.

- Add toJSON() methods to all Expr classes for JSON.stringify support
- Add exprFromJSON() deserializer for reconstructing from JSON
- Add Fable.Core.Quotations.fs with QuotExpr/QuotVar types for
  .NET-side deserialization (e.g. via Thoth.Json)
- Add JSON serialization tests
Copy link
Copy Markdown
Collaborator

@dbrattli dbrattli left a comment

Choose a reason for hiding this comment

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

Code Review

Nice clean integration with the existing QuotationEmitter infrastructure — the Fable2Babel.fs changes are minimal and the approach is consistent with Python/Beam targets. A few items to address:

Incomplete traversal in getFreeVars and substitute

Both getFreeVars and substitute in quotation.ts are missing recursion into several AST node types: ExprNewUnion, ExprNewRecord, ExprNewList, ExprFieldSet, ExprVarSet, ExprUnionTag, ExprUnionField. Free variables inside these nodes would be silently missed / not substituted.

Loose any typing

Several fields use any where more specific types apply:

  • ExprCall.instanceExpr | null
  • ExprCall.argsExpr[]
  • ExprNewTuple.elementsExpr[]
  • ExprNewUnion.fieldsExpr[]
  • ExprNewRecord.fieldNamesstring[]
  • ExprNewRecord.valuesExpr[]

Simplify substitute instance check

The verbose instanceof chain for ExprCall.instance in substitute:

const newInst = e.instance instanceof ExprVarExpr || e.instance instanceof ExprValue
    || e.instance instanceof ExprLambda || ...
    ? sub(e.instance) : e.instance;

can be simplified to:

const newInst = e.instance != null ? sub(e.instance) : e.instance;

Minor

  • op_Division always truncates to integer via | 0 — will produce wrong results for float quotations. Can be fixed for all targets in a follow-up PR.

Overall looks good 👍

- Complete getFreeVars/substitute traversal for all AST nodes:
  ExprNewUnion, ExprNewRecord, ExprNewList, ExprFieldSet, ExprVarSet,
  ExprUnionTag, ExprUnionField
- Tighten types: Expr | null for ExprCall.instance, Expr[] for args/
  elements/fields/values, string[] for fieldNames
- Simplify substitute instance check to: e.instance != null ? sub(...)
- Remove integer truncation (| 0) from op_Division
Copy link
Copy Markdown
Collaborator

@dbrattli dbrattli left a comment

Choose a reason for hiding this comment

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

This looks good to me 👍🏻 Perhaps the only concern is the JSON format in Fable.Core. I think it's nice and would love to use it myself. There is no .NET standard for this, so this is Fable specific. Reminds me of Reaqtor Bonsai trees. But its optional and anyone could make their own format if they wanted to so I don't see any problems adding it. I say we add it and test it out over a few -rc releases to see how this works. I will let @MangelMaxime have the last word before merging.

Change toJSON() output from ["Tag", ...fields] arrays to
{"Case":"Tag","Fields":[...]} objects. This matches Thoth.Json's
standard DU encoding, enabling Decode.Auto.fromString<QuotExpr>(json)
on .NET with zero custom decoders.
@OnurGumus
Copy link
Copy Markdown
Contributor Author

@dbrattli I added one change, that would make thoth to be able to deserialize

@MangelMaxime
Copy link
Copy Markdown
Member

I added one change, that would make thoth to be able to deserialize

It depends what you mean by that.

In general, Thoth.Json don't really have a fixed format by default because of the manual API.

However, there are 2 places where we have format in place:

  1. In the new API, I introduce a lossless encoding for 'T option which uses $case and $value fields

    https://github.com/thoth-org/Thoth.Json/blob/bf14913d5a161258c1cb3d4589b512f9fb56d50b/packages/Thoth.Json.Core/Decode.fs#L1099-L1116

  2. The Auto API, which is not yet part of the new API (Thoth.Json.Core)

    Encode DUs using arrays with the first element being the name of the case

    https://thoth-org.github.io/Thoth.Json/documentation/auto/json-representation.html#tuple-with-arguments

I believe you wanted to make it compatible with the later? @OnurGumus

Change toJSON() from {"Case":"Tag","Fields":[...]} objects to
["Tag", ...fields] arrays, matching Thoth.Json Auto encoding.
On .NET, deserialize with FSharp.SystemTextJson ThothLike:

  options.Converters.Add(JsonFSharpConverter(JsonUnionEncoding.ThothLike))

Verified end-to-end: Fable JS -> JSON -> .NET deserialize -> evaluate.
@ncave
Copy link
Copy Markdown
Collaborator

ncave commented Apr 7, 2026

@OnurGumus @MangelMaxime
Just a question, not suggesting any change, just clarifying things for myself:

If we're using a different structure for the Fable quotations, instead of the F# quotations (which require full reflection), do you think it is possible to convert it to F# implementation that serializes the same way, instead of a TypeScript implementation? This way we might be able to share it more uniformly between other Fable targets.

@MangelMaxime MangelMaxime merged commit 9918458 into fable-compiler:main Apr 7, 2026
30 checks passed
@MangelMaxime
Copy link
Copy Markdown
Member

@ncave Do you mean by writing quotations.ts as much as possible in F# code in quotation.fs

Like done by List.fs, etc. ?

@OnurGumus @dbrattli Quotations have been released in Fable RC 7

@ncave
Copy link
Copy Markdown
Collaborator

ncave commented Apr 7, 2026

@MangelMaxime Yes, I was just wondering if we can use the same quotations.fs across all targets.

@OnurGumus
Copy link
Copy Markdown
Contributor Author

@ncave Yes, a good chunk of it could be shared F#. The data types, constructors (mkValue, mkLambda, mkCall etc.), and the pure tree-walking functions (evaluate, getFreeVars, substitute, exprToString) are all just plain data structures and recursion, no target-specific code needed. That part would work the same way Seq.fs or Map.fs are shared across targets today.

The part that would still need to be per-target is serialization. The JS toJSON() method hooks into JSON.stringify automatically, Python uses dataclasses with snake_case, Erlang uses tuples. Those idioms don't translate from a single F# source.

So maybe 70% could become a shared Quotation.fs, with a thin per-target layer for serialization.

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.

5 participants