|
| 1 | +--- |
| 2 | +layout: fable-blog-page |
| 3 | +title: Better Typed than Sorry |
| 4 | +author: Alfonso García-Caro |
| 5 | +date: 2023-04-20 |
| 6 | +author_link: https://github.com/alfonsogarciacaro |
| 7 | +author_image: https://github.com/alfonsogarciacaro.png |
| 8 | +# external_link: |
| 9 | +abstract: | |
| 10 | + Use the new TypeScript compilation target to integrate F# in existing projects or publish npm libraries with beautiful type-safe APIs. |
| 11 | +--- |
| 12 | + |
| 13 | +If you are a Fable user you may have noticed that we released, quite quietly, Fable 4 a few weeks ago. For existing F# to JS projects, Fable 4 brings exciting new features like dotnet 7 and [JSX](../2022/2022-10-12-react-jsx.html) support, but most importantly Fable 4 takes F# beyond JS with the addition of new targets like [Python, Rust or Dart](../2022/2022-06-06-Snake_Island_alpha.html). Though the new major release didn't mean all targets are ready for production, as [we discussed in a previous post](../2022/2022-09-28-fable-4-theta.html#language-status), we have decided to keep a single tool but let each language evolve at its own pace. |
| 14 | + |
| 15 | +> If you haven't upgraded yet, we recommend you do it soon: there are no breaking changes and you [only need to update a few packages](../2022/2022-09-28-fable-4-theta.html#packages-updated-for-fable-4). |
| 16 | +
|
| 17 | +With Fable 4.1 we are happy to announce one of those targets is ready for prime-time! Thanks to the effort of [ncave](https://github.com/ncave) who has been leading the effort to add type annotations to JS output (in spite of my initial reluctance) for a long time, TypeScript compilation started to become a real possibility. We have been working together in the last month and TypeScript generated code is almost on par now with JS so we have decided to bump the status from beta to stable 🚀 |
| 18 | + |
| 19 | +Wait a moment, compiling to TypeScript? Does this mean I should compile all my JS apps to TypeScript from now on for extra type-safety? No, we don't recommend that. In fact, you probably shouldn't. Many Fable libraries already emit JS code that TypeScript may not accept happily and we cannot control that. TypeScript compilation is intended for integrating F# into existing TypeScript and to write libraries that can be published to npm and consumed with a type-safe API. This is in line with the focus we are giving to the new language targets: instead of trying to do everything in F#, we will concentrate our efforts on having a great experience with [Domain Programming](https://fsharpforfunandprofit.com/ddd/) in all platforms. This means spending less time writing bindings for native libraries and instead working on generating nice code that can be easily consumed. |
| 20 | + |
| 21 | +> All-F# frontend apps will still be supported for JS compilation, and it's also possible for contributors to extend Fable for Python/Rust/Dart to support all-F# projects (writing bindings or providing templates and tutorials, etc). |
| 22 | +
|
| 23 | +As always, the best way to understand what TypeScript compilation can do for you is to get your hands on a project, and there's no better one than [compostjs](https://compostjs.github.io/compost/), another great library from [Tomas Petricek](https://tomasp.net/) to create charts through composition. Compostjs is written in F#, compiled to JS and published to npm. It already offers a nice and [documented API](https://compostjs.github.io/compost/api.html). But the IDE experience is not that great, because the type information was missing in the compilation... until now. [This fork](https://github.com/alfonsogarciacaro/compost/) adapts compostjs to be compiled to TypeScript with Fable 4.1, and it also enables fluent APIs that are self-discoverable and can be type-checked also for TS/JS consumers! |
| 24 | + |
| 25 | +<img src="./compost-typed.webp" style="max-width: 50rem; margin: 2rem auto; display: block;" /> |
| 26 | + |
| 27 | +If you want to try it out, clone [the fork](https://github.com/alfonsogarciacaro/compost/) and run the following commands: |
| 28 | + |
| 29 | +```bash |
| 30 | +dotnet tool restore |
| 31 | +npm install |
| 32 | +npm start |
| 33 | +``` |
| 34 | + |
| 35 | +Then you can open `src\project\App.tsx`, edit the code and see the changes in the browser and how the IDE will guide you and warn of potential mistakes. |
| 36 | + |
| 37 | +## How to expose a nice TypeScript API |
| 38 | + |
| 39 | +The mechanisms to expose a nice API are the same as [when consuming JS code from F#](https://fable.io/docs/communicate/js-from-fable.html#type-safety-with-imports-and-interfaces). First, use **interfaces** to define the contracts between F# and native code. This is because Fable by default won't mangle interfaces. You can use interfaces when [exporting](https://github.com/alfonsogarciacaro/compost/blob/e783ed687bc19887f02aa0b071585a1a152c8956/src/compost/compost.fs#L80-L110), [creating a fluent API](https://github.com/alfonsogarciacaro/compost/blob/e783ed687bc19887f02aa0b071585a1a152c8956/src/compost/compost.fs#L203-L226) or declaring [complex arguments](https://github.com/alfonsogarciacaro/compost/blob/e783ed687bc19887f02aa0b071585a1a152c8956/src/compost/compost.fs#L14-L22) (this is particularly useful for dependencies that need to be injected from native code): |
| 40 | + |
| 41 | +```fsharp |
| 42 | +open Fable.Core |
| 43 | +open Browser.Types |
| 44 | +
|
| 45 | +type Coord = U2<float, obj * float> |
| 46 | +type Point = Coord * Coord |
| 47 | +
|
| 48 | +type Handlers = |
| 49 | + abstract mousedown: (Coord -> Coord -> MouseEvent -> unit) option |
| 50 | + abstract mouseup: (Coord -> Coord -> MouseEvent -> unit) option |
| 51 | + abstract mousemove: (Coord -> Coord -> MouseEvent -> unit) option |
| 52 | +
|
| 53 | +type CompostShape = |
| 54 | + abstract on : handlers: Handlers -> CompostShape |
| 55 | +
|
| 56 | +type Compost = |
| 57 | + ... |
| 58 | + interface CompostShape with |
| 59 | + member s.on(h: Handlers) = |
| 60 | + Shape.Interactive([ |
| 61 | + match h.mousedown with None -> () | Some f -> yield MouseDown(fun me (x, y) -> f (formatValue x) (formatValue y) me) |
| 62 | + match h.mouseup with None -> () | Some f -> yield MouseUp(fun me (x, y) -> f (formatValue x) (formatValue y) me) |
| 63 | + match h.mousemove with None -> () | Some f -> yield MouseMove(fun me (x, y) -> f (formatValue x) (formatValue y) me) |
| 64 | + ], s) |
| 65 | +``` |
| 66 | + |
| 67 | +Because all `Handlers` fields are optional, the method can be called from JS/TS passing only a number of them: |
| 68 | + |
| 69 | +```ts |
| 70 | +c.line(data) |
| 71 | + .strokeColor("#202020") |
| 72 | + .on({ |
| 73 | + mousedown: (x, y) => console.log("CLICK!", x, y) |
| 74 | + }) |
| 75 | +``` |
| 76 | + |
| 77 | +> A similar effect can be reached with [anonymous records](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/anonymous-records). |
| 78 | +
|
| 79 | +### Exporting members |
| 80 | + |
| 81 | +A disadvantage of exporting an object expression is it won't play well with [tree shaking](https://developer.mozilla.org/en-US/docs/Glossary/Tree_shaking), so you can use a plain module instead. Just make sure there is only **one single root module** in the file to prevent automatic mangling: |
| 82 | + |
| 83 | +```fsharp |
| 84 | +// It doesn't matter if the module is nested in a namespace |
| 85 | +module MyNamespace.MyModule |
| 86 | +
|
| 87 | +let add x y = x + y |
| 88 | +let multiply x y = x * y |
| 89 | +``` |
| 90 | + |
| 91 | +This method is not perfect either because F# module methods cannot have optional arguments. If you need them you can use an erased static class with `Mangle(false)` attribute. |
| 92 | + |
| 93 | +```fsharp |
| 94 | +namespace MyNamespace |
| 95 | +
|
| 96 | +open Fable.Core |
| 97 | +
|
| 98 | +[<Erase; Mangle(false)>] |
| 99 | +type MyClass = |
| 100 | + static member add (x: int, ?y: int) = x + defaultArg y 0 |
| 101 | + static member multiply (x: int, ?y: int) = x * defaultArg y 1 |
| 102 | +``` |
| 103 | + |
| 104 | +The produced JS/TS code is the same as with the module above (except for the optional arguments). As with interfaces, in this case overloads cannot be used and you also need to be careful to avoid name conflicts. |
| 105 | + |
| 106 | +### Erased unions |
| 107 | + |
| 108 | +Precisely, because overloads are not supported in JS, TypeScript often uses what we call _erased_ unions (to tell them apart from _actual_ F# unions) to allow different types of arguments. Fable can [represent these in F#](https://fable.io/docs/communicate/js-from-fable.html#erase-attribute) with unions decorated with `Erase` attribute. You can expose them in your APIs and even use pattern matching, but be aware this is translated to standard JS runtime testing (`typeof`, `instanceof`, `Array.isArray`...) so only use erased unions with [disctinct JS primitives](https://fable.io/docs/dotnet/compatibility.html#net-base-class-library) (e.g. no `U2<int, float>`). |
| 109 | + |
| 110 | +```fsharp |
| 111 | +open Fable.Core.JsInterop |
| 112 | +
|
| 113 | +type Coord = U2<float, obj * float> |
| 114 | +
|
| 115 | +let private formatValue (v: Value): Coord = |
| 116 | + match v with |
| 117 | + | COV(CO v) -> U2.Case1(v) // or ^!v |
| 118 | + | CAR(CA c, r) -> U2.Case2(c, r) // or ^!(c, r) |
| 119 | +
|
| 120 | +let private parseValue (v: Coord): Value = |
| 121 | + match v with |
| 122 | + | U2.Case1 v -> COV(CO(v)) |
| 123 | + | U2.Case2 (a1, a2) -> CAR(CA(string a1), a2) |
| 124 | +``` |
| 125 | + |
| 126 | +Even if the declaration is erased the correct type is shown for the return and argument types in TypeScript: |
| 127 | + |
| 128 | +```ts |
| 129 | +function formatValue(v: Value_$union): float64 | [any, float64] { |
| 130 | + if (v.tag === /* CAR */ 0) { |
| 131 | + const r: float64 = v.fields[1]; |
| 132 | + const c: string = v.fields[0].fields[0]; |
| 133 | + return [c, r] as [any, float64]; |
| 134 | + } |
| 135 | + else { |
| 136 | + const v_1: float64 = v.fields[0].fields[0]; |
| 137 | + return v_1; |
| 138 | + } |
| 139 | +} |
| 140 | + |
| 141 | +function parseValue(v: float64 | [any, float64]): Value_$union { |
| 142 | + if (isArrayLike(v)) { |
| 143 | + const a2: float64 = v[1]; |
| 144 | + const a1: any = v[0]; |
| 145 | + return Value_CAR(new categorical(toString(a1)), a2); |
| 146 | + } |
| 147 | + else { |
| 148 | + const v_1: float64 = v; |
| 149 | + return Value_COV(new continuous(v_1)); |
| 150 | + } |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +### Named arguments |
| 155 | + |
| 156 | +While JS/TS accept optional arguments, they cannot be ommitted unless they are at the end position. Because of this, it is common to pass a dictionary object instead (as with Python). In Fable you could use the `NamedParams` to represent this situation in bindings, and now you can also use it when declaring your own APIs: |
| 157 | + |
| 158 | +```fsharp |
| 159 | +type CompostShape = |
| 160 | + [<NamedParams>] |
| 161 | + abstract axes: ?top: bool * ?right: bool * ?bottom: bool * ?left: bool -> CompostShape |
| 162 | +
|
| 163 | + // If there are positional arguments too, indicate where the named arguments start |
| 164 | + [<NamedParams(fromIndex=1)>] |
| 165 | + abstract mixMethod: positionalArg: float * ?namedArg1: string * ?namedArg2: int -> unit |
| 166 | +``` |
| 167 | + |
| 168 | +This can be consumed from TypeScript like: |
| 169 | + |
| 170 | +```ts |
| 171 | +shape.axes({ right: true, bottom: true, left: true }) // ok |
| 172 | +shape.axes({ top: "false" }) // TS error, wrong type |
| 173 | +shape.axes({ vertical: true }) // TS error, wrong name |
| 174 | +``` |
| 175 | + |
| 176 | +### F# Unions in TypeScript |
| 177 | + |
| 178 | +We'll finish this post talking about another interesting feature of the TypeScript compilation. Besides _erased_ unions you can also expose _actual_ F# unions and even do pattern matching in TypeScript! This is because Fable uses TypeScript literal types to annotate the unions with their tag. Given an example like this: |
| 179 | + |
| 180 | +```fsharp |
| 181 | +type MyUnion = |
| 182 | + | Foo of string * string |
| 183 | + | Bar of float |
| 184 | + | Baz |
| 185 | +
|
| 186 | +let test (x: MyUnion) = |
| 187 | + match x with |
| 188 | + | Foo (_, b) -> b.ToUpperInvariant() |
| 189 | + | Bar a -> a + 5.3 |> sprintf "%f" |
| 190 | + | Baz -> "Baz" |
| 191 | +``` |
| 192 | + |
| 193 | +If you inspect the generated code, you will see how Fable generates extra types in TypeScript for each union: |
| 194 | + |
| 195 | +```ts |
| 196 | +export type MyUnion_$union = MyUnion<0> | MyUnion<1> | MyUnion<2>; |
| 197 | + |
| 198 | +export type MyUnion_$cases = { 0: ["Foo", [string, string]], 1: ["Bar", [float64]], 2: ["Baz", []] }; |
| 199 | + |
| 200 | +export class MyUnion<Tag extends keyof MyUnion_$cases> extends Union<Tag, MyUnion_$cases[Tag][0]> { |
| 201 | + constructor(readonly tag: Tag, readonly fields: MyUnion_$cases[Tag][1]) { |
| 202 | + super(); |
| 203 | + } |
| 204 | + cases() { |
| 205 | + return ["Foo", "Bar", "Baz"]; |
| 206 | + } |
| 207 | +} |
| 208 | + |
| 209 | +export function test(x: MyUnion_$union): string { |
| 210 | + // Here the types of `fields` are still unknown |
| 211 | + switch (x.tag) { |
| 212 | + case /* Foo */ 0: { |
| 213 | + // Here TypeScript knows that `fields` is a tuple of two strings |
| 214 | + const b: string = x.fields[1]; |
| 215 | + return b.toUpperCase(); |
| 216 | + } |
| 217 | + case /* Bar */ 1: { |
| 218 | + // If you hover over `x` within this branch, you'll see |
| 219 | + // `MyUnion_$union` type has collapsed to `MyUnion<1>` |
| 220 | + const a: float64 = x.fields[0]; |
| 221 | + return toText(printf("%f"))(a + 5.3); |
| 222 | + } |
| 223 | + // If you comment out any of the cases or use a number outside |
| 224 | + // the tag range, TypeScript will complain |
| 225 | + case /* Baz */ 2: |
| 226 | + return "Baz"; |
| 227 | + } |
| 228 | +} |
| 229 | +``` |
| 230 | + |
| 231 | +> The extra types are just aliases that only exists in TypeScript, so they won't have any effect in the runtime. |
| 232 | +
|
| 233 | +A switch against a number is very fast. TypeScript consumers, however, may find it more convenient to match against the name. They can do it with the same level of type safety: |
| 234 | + |
| 235 | +```ts |
| 236 | +switch (x.name) { |
| 237 | + case "Foo": { |
| 238 | + const b: string = x.fields[1]; |
| 239 | + return b.toUpperCase(); |
| 240 | + } |
| 241 | + case "Bar": { |
| 242 | + const a: float64 = x.fields[0]; |
| 243 | + return toText(printf("%f"))(a + 5.3); |
| 244 | + } |
| 245 | + case "Baz": |
| 246 | + return "Baz"; |
| 247 | +} |
| 248 | +``` |
| 249 | + |
| 250 | +Same as with tags, TypeScript will check you are using the correct case names, whether the switch is comprehensive and the correct `fields` types within each branch. |
| 251 | + |
| 252 | +This is not the only convenience that Fable TypeScript compilation offers. When you need to instantiate an F# union from TS it's cumbersome to use the tag as a generic argument, so convenience helpers are offered as constructors for each case: |
| 253 | + |
| 254 | +```ts |
| 255 | +test(MyUnion_Foo("a", "b")) |
| 256 | +test(MyUnion_Bar(5)) |
| 257 | +test(MyUnion_Baz()) |
| 258 | +``` |
| 259 | + |
| 260 | +--- |
| 261 | + |
| 262 | +That was it! There are still a few things missing in TypeScript compilation, but we believe it's already in a state that will let you integrate F# in TypeScript projects and/or write npm libraries in F# with confidence. We hope you find it useful and we are looking forward for all the great things you are going to build with F# and Fable. Make sure to let us know! |
0 commit comments