diff --git a/docs/blog/2026/2026-09-22-Introducing-Xantham.md b/docs/blog/2026/2026-09-22-Introducing-Xantham.md new file mode 100644 index 00000000..952a146f --- /dev/null +++ b/docs/blog/2026/2026-09-22-Introducing-Xantham.md @@ -0,0 +1,400 @@ +--- +layout: fable-blog-page +title: "Xantham: npm packages, meet F#" +author: Shayan Habibi +date: 2026-09-22 +author_link: https://github.com/shayanhabibi +author_image: https://github.com/shayanhabibi.png +abstract: | + 1,600 particles rippling in rotation and colour, and no handwritten Anime.js bindings. + Generate the package's F# API with Xantham, compile with Fable, run it in the browser. +--- + +![Xantham — TypeScript to F# bindings](/static/img/blog/xantham-workflow-banner.png) + +![1,600 coloured particles rippling outward from the centre, rotating and shrinking](/static/img/blog/xantham-particles.gif) + +[Anime.js](https://animejs.com/) driven from F# through a binding Xantham generated. + +[Xantham](https://shayanhabibi.github.io/Xantham/) turns an npm package's +TypeScript declarations into F# bindings using TypeScript 7's native compiler API. +It follows declarations across files, generates runtime imports and public +subpath modules, and can generate or reuse dependency bindings. + +## Install → generate + +> Prerequisites: +> * .NET10 +> * Node.js 22.12+ + +```sh +mkdir xantham-demo +cd xantham-demo +dotnet new console -lang F# -n Demo --framework net10.0 +npm init -y +npm pkg set type=module +npm install --save-exact animejs@4.5.0 +npm install --save-dev --save-exact vite@8.3.0 +dotnet new tool-manifest +dotnet tool install xantham --version 0.1.0 +dotnet tool install fable --version 5.13.0 +dotnet xantham tsc init +dotnet xantham generate node_modules/animejs -o bindings +``` + +That writes `bindings/Animejs.fs`, plus `manifest.json` and `symbols.jsonl` (graded +below). `tsc init` fetches the TypeScript 7 compiler. + +Replace `Demo.fsproj` with: + +```xml + + + Exe + net10.0 + + + + + + + + + +``` + +`Xantham.Fable.Core.TS` supplies the DOM types and pulls in Fable.Core. + +## Animate + +Replace `Program.fs`: + +```fs +module Demo +open Fable.Core +open Fable.Core.JsInterop +open Fable.Core.TS +open Animejs + +[] +let document: Dom.Document = jsNative +let field = document.getElementById("field") |> Option.get + +for i in 0 .. 1599 do + let dot = document.createElement "i" + dot.setAttribute("style", $"background:hsl({i % 40 * 9}, 85%%, 65%%)") + field.appendChild dot |> ignore + +let wave = Exports.stagger(35., StaggerParams.Create( + grid = !^ [| 40.; 40. |], from = !^ "center")) + +let options = jsOptions(fun p -> + p.duration <- Some (!^ 1800.) + p.delay <- Some (!^ (DurationKeyframes.Item.Duration( + fun target index targets previous -> + !^ (wave.Invoke(target, index, targets, previous, None))))) + p.alternate <- Some true + p.loop <- Some (!^ true) + p.ease <- Some (!^ "inOutSine") + p.["rotate"] <- !^ 360. + p.["scale"] <- !^ 0.15) + +let animation = Exports.animate(!^ "#field i", options) +``` + +`Exports.animate`, `Exports.stagger` and the option types all come from +`bindings/Animejs.fs`, which is unedited generator output. `Dom.Document` comes from +Xantham too; `[]` ties it to the browser's `document`. The one piece of glue +is the delegate, which adapts stagger's five arguments to the delay callback's four. + +Add `index.html`: + +```html + + +Xantham: 1,600 particles, one generated binding + +
+ +``` + +## Compile → run + +```sh +dotnet fable Demo.fsproj --outDir dist +npx vite +``` + +Open the local URL. + +The GIF at the top is this build, captured in headless Chrome with no page errors. + +## Beyond one package + +Past one package you'll want `xantham.json`, passed with `--config`. + +**Names are yours.** `module` sets the generated F# module — it defaults to the npm +name, so `@scope/pkg-name` becomes `Scope.PkgName` — and `namespace` wraps related +packages in one namespace. `runtime` overrides the JavaScript import path +when it differs from the package name. + +**Four ways to handle a dependency.** When a declaration reaches into another +package, `groups` decides what happens, keyed by npm name: + +```json +{ + "namespace": "MyBindings", + "groups": { + "shared-models": "ship", + "another-package": "reference", + "typescript/lib": { "map": { "RegExp": "System.Text.RegularExpressions.Regex" } } + } +} +``` + +`ship` emits the dependency's declarations alongside yours, `reference` points at a +separately generated binding, `map` redirects named types to F# types that already +exist, and `widen` — the default for anything unlisted — renders the reference as +`obj` and files a finding. + +**Declaration catalogues keep identities stable.** Two bindings that mention the same +TypeScript type should share one F# type. A producer sets `declarationCatalog: true` +and writes a +`declarations.json` next to its output; a consumer lists it in +`declarationReferences` and reuses those identities while keeping its own imports: + +```json +{ + "module": "Example.Adapter", + "entry": "adapter.d.ts", + "runtime": "example/adapter", + "declarationReferences": ["/bindings/root/declarations.json"] +} +``` + +A catalogue whose package hashes or F# API no longer match is rejected with a +diagnostic. + +**Every symbol is graded.** Alongside the binding, each run writes `manifest.json` +and `symbols.jsonl`, which sorts Anime.js's 351 symbols into four tiers: + +| Tier | Count | Meaning | +|---|---|---| +| `exact` | 129 | the F# type says what the TypeScript type said | +| `ergonomic` | 129 | reshaped for F#, same meaning | +| `widened` | 14 | detail lost — the `StaggerParams.from` case below | +| `escape` | 79 | an escape hatch such as `obj` is in play | + +Read the 93 `widened` and `escape` entries, not the 5,000-line `Animejs.fs`. + +## What about Glutinum? + +[Glutinum](https://github.com/glutinum-org/cli) is the established TypeScript-to-F# +generator for Fable. Same input: + +```sh +npm install --save-dev --save-exact @glutinum/cli@0.14.1 +npx glue animejs --out-file Glutinum.Animejs.fs +``` + +The command succeeds. The file it writes [does not compile](https://github.com/glutinum-org/cli/issues/220) +against `Fable.Core` 5.2.0 and `Glutinum.Web` 0.1.0: + +```text +Glutinum.Animejs.fs(1732,14): error FS0037: Duplicate definition of type, exception or module 'Animatable' +``` + +The generator emits the real declaration and a `type Animatable = obj` placeholder +in the same module; 18 types collide this way. It is specific to this package — +Glutinum's `@types/node` binding compiles clean. + +### Imports have to resolve + +Anime.js publishes an `exports` map, so only the subpaths it lists are importable. +All 181 of Xantham's import sites point at a listed subpath: `animejs`, +`animejs/utils`, `animejs/svg`, `animejs/easings/spring`. Of Glutinum's 578, 279 +target 28 paths the map does not expose, reaching into `dist/` instead: + +```sh +$ node -e "import('animejs/dist/modules/core/helpers.js')" +ERR_PACKAGE_PATH_NOT_EXPORTED: Package subpath './dist/modules/core/helpers.js' +is not defined by "exports" +``` + +Xantham reads the `exports` map and mirrors the public subpaths as nested modules, +so `animejs/svg` becomes `Animejs.Svg`. + +`@types/node` 22.20.2 shows the same split for a different reason. All 2,047 of +Xantham's import sites are `node:`-prefixed — `node:fs`, `node:crypto`, +`node:stream`. Glutinum emits 2,634, of which 165 point at `undici-types`: a +types-only package whose directory holds `.d.ts` files and nothing else, with no +`index.js` and no `undici-types/fetch.js` to import. + +```sh +$ node -e "import('undici-types/fetch.js')" # ERR_MODULE_NOT_FOUND +$ node -e "import('node:fs')" # resolves +``` + +Bare `fs` resolves too, so the prefix is not the point; it only rules out a +same-named npm package, and Deno and Bun prefer it. + +Back on animejs, both tools read the same `animate(targets, params)`. + +### Unions stay unions + +TypeScript types the first argument as `TargetsParam`, a union of six things. +Xantham keeps it as one member over a named erased union: + +```fs +[] +static member animate (targets: TargetsParam, parameters: AnimationParams) : JSAnimation = jsNative + +type TargetsParam = + U6 +``` + +Glutinum expands the union into one overload per case: + +```fs +static member animate (targets: ResizeArray, parameters: Animejs.dist_modules_types.AnimationParams) : Animejs.dist_modules_animation.JSAnimation = nativeOnly +static member animate (targets: Glutinum.Web.HTMLElement, parameters: Animejs.dist_modules_types.AnimationParams) : Animejs.dist_modules_animation.JSAnimation = nativeOnly +static member animate (targets: Glutinum.Web.SVGElement, parameters: Animejs.dist_modules_types.AnimationParams) : Animejs.dist_modules_animation.JSAnimation = nativeOnly +// … and three more +``` + +Overloads read well at a call site with a known argument type. They stop working +when the value *is* a union. + +Measured against Fable.Core 5.2.0 on net10.0: + +| Shape | Call style | Result | +|---|---|---| +| union only | `f(!^ x)` | compiles | +| union only | bare lambda into a delegate arm | **FS0002** | +| arms only, union member dropped | argument held at union type | **FS0041** | +| union + arms | plain arm value, `f("x")` | compiles | +| union + arms | `f(!^ x)` | **FS0041** | + +Row three is Glutinum's shape: a value already held at `TargetsParam` has to be matched +out and re-entered at a concrete type. Row five is why adding the union member back +does not help — beside the arms, `!^` loses its unique target. + +Row two is the case for overloads. A bare lambda has no target type to infer against +inside a union, so `!^ (fun a b -> "x")` is FS0002 unless you write +`System.Func<_,_,_>(fun a b -> "x")` by hand. An overload for the delegate arm lets +the lambda infer. + +Which is better depends on the calling code, so arm expansion is opt-in: + +```json +{ "unionArmOverloads": { "enabled": false, "maxArms": 4 } } +``` + +Members whose arms collapse to one F# signature — `U2`, or two arms +that both map to `obj` — are skipped, since that overload set would be FS0041 at +every call site. The manifest lists which and why. + +### Module names come from declarations, not directories + +Glutinum names modules after the file each declaration came from, and references +types by that path: + +```fs +namespace rec Glutinum + +module Animejs = + module dist_modules_types = … + module dist_modules_adapters_three_adapter = … + // and referenced as Animejs.dist_modules_types.AnimationParams +``` + +Xantham emits `module rec Animejs` and nests by declaration — `Animatable`, +`DurationKeyframes.Item`, `Utils.Stagger.Result` — so types are referenced by +short name. + +### Option objects get constructors + +Anime.js option bags are all-optional interfaces. Xantham synthesises a `Create` +factory for each, which is why the demo can write +`StaggerParams.Create(grid = …, from = …)`: + +```fs +static member Create (?start: TimelinePosition, ?from: U3, + ?reversed: bool, ?grid: U2, …) : StaggerParams = jsNative +``` + +There are 66 such factories in the Xantham output and none in Glutinum's, so there +you write `jsOptions` or an object expression instead. + +### Where Glutinum does it better + +For `from?: number | "first" | "center" | "last" | "random" | Array`, +Glutinum keeps the string literals as named cases: + +```fs +[] +[] +type from = + | first + | center + | last + | random + | Case1 of float + | Case2 of ResizeArray +``` + +Xantham widens the literals to `string` — `U3` — so +`from = !^ "center"` in the demo is an unchecked string where Glutinum would have +offered `from.center`. Xantham records the loss: `symbols.jsonl` marks +`StaggerParams` as `widened` and cites `TR006` +(*string literal type widened to string*). Both tools agree on the simpler +`axis?: "x" | "y" | "z"`, which each emits as a `StringEnum`. + +Not an animejs quirk: `@types/node` has 18 of the same shape. + +| TypeScript | Glutinum | Xantham | +|---|---|---| +| `family?: "IPv4" \| "IPv6" \| number` | `IPv4 \| IPv6 \| Case1 of float` | `U2` | +| `BufferEncodingOption = "buffer" \| { encoding: "buffer" }` | `buffer \| Case1 of …` | `U2` | +| `StdioNull` | `ignore \| Case1 of …` | widened | + +`fs.realpath` takes exactly `"buffer"` for its options; Xantham's signature accepts +`!^ "utf8"` and compiles. + +Xantham does not emit that DU automatically because its safety rests on Fable being +able to type-test each payload arm. For `from` it can: `float` and `ResizeArray` +lower to `typeof x === "number"` and `Array.isArray(x)`. For a union of two interface +types, or of anything Fable erases, it cannot, and it fails two ways: + +| Failure | Fable's response | Result | +|---|---|---| +| an arm Fable cannot type test | `warning FABLE: Cannot type test (evals to false): T` | that branch is silently absent | +| two arms sharing one type test | nothing at all | the second branch is silently dead | + +Both compile. When the DU is safe to emit is a judgement about the arms, and Xantham +does not yet make it; it records `TR006` instead. + +Two more from `@types/node`. Glutinum overloads at small arity — `fetch` gets three +members taking `string`, `URL` and `Request` — where Xantham has one +`U3` that needs `!^` at every call. The `TargetsParam` argument +above is about six arms; at three, the overloads are just nicer. And Glutinum reads +TypeScript's `Array` as `ResizeArray`, 865 times, where Xantham emits `T[]`, +2,378 times. F# arrays are fixed-size, so `push` and `splice` on a JS array need a +cast first. + +On the other side, Glutinum leaves 97 types as `interface end` — the global +`RequestInit` and `Response` among them — against Xantham's 3, and 43 `= obj` +abbreviations against 8. Xantham emits 596 `StringEnum`s to Glutinum's 131: it wins +pure literal unions and loses mixed ones. + +Two packages, one pair of versions each. Anime.js 4.5.0 spreads its API over 70 +declaration files and is a hard case. + +Next package? Change the input directory. +[Start with the docs](https://shayanhabibi.github.io/Xantham/xantham-cli/), +or explore [dependency generation and shared types](https://shayanhabibi.github.io/Xantham/xantham-cli/guide/dependencies/). diff --git a/docs/static/img/blog/xantham-particles.gif b/docs/static/img/blog/xantham-particles.gif new file mode 100644 index 00000000..8675291e Binary files /dev/null and b/docs/static/img/blog/xantham-particles.gif differ diff --git a/docs/static/img/blog/xantham-workflow-banner.png b/docs/static/img/blog/xantham-workflow-banner.png new file mode 100644 index 00000000..e1e60514 Binary files /dev/null and b/docs/static/img/blog/xantham-workflow-banner.png differ