Type-safe JavaScript Development with F# and Fable

F# is a suitable alternative to TypeScript for writing a JavaScript application with type-safety. But you also get the additional benefits of F#'s powerful type inference.

TypeScript

interface Person {
  firstName: string
  lastName: string
}

let people: Person[] = [
  { firstName: 'John', lastName: 'Doe' },
  { firstName: 'Jane', lastName: 'Doe' },
]

let names = people.map(({ firstName, lastName }) => `${firstName} ${lastName}`)

Here is a simple TypeScript example where an array of Person is mapped to strings. In the inline mapping function, the parameter { firstName, lastName} does not need to have explicit type annotation because TypeScript can infer the type from the fact that it is mapping over an array of type Person[], therefore the type at each index must be Person.

TypeScript's type inference is not able to do the same if we extract the callback into a named function or a variable though.

interface Person {
  firstName: string;
  lastName: string;
}

let people: Person[] = [
  { firstName: 'John', lastName: 'Doe' },
  { firstName: 'Jane', lastName: 'Doe' },
]

let combineNames = ({ firstName, lastName }) => `${firstName} ${lastName}`

let names = people.map(combineNames)

In this refactor, we'll now get a TypeScript eror (at least in strict mode) that the type of { firstName, lastName } is implicitly any. To handle that, an annotation can be added to the combineNames function like this:

let combineNames: (p: Person) => string = ({ firstName, lastName }) => `${firstName} ${lastName}`

F#

F# has a mature and popular compiler called Fable which can compile type-safe F# code to JavaScript. Here's the same code in F#:

type Person = {
    firstName: string
    lastName: string }

let people = [
    { firstName = "John"; lastName = "Doe" }
    { firstName = "Jane"; lastName = "Doe" } ]

let names =
    people
    |> List.map (fun p -> $"{p.firstName} {p.lastName}")

In this example, there is no type annotation on the people list. This is because F# knows that that type in the list matches the type Person and infers that is is a Person list. Changing one of the field names on one of the entries, removing a field, or adding a field will result in a compilation error.

We can also pull out the function we're using for List.map into a named binding and it's type will be inferred too:

type Person = {
    firstName: string
    lastName: string }

let people = [
    { firstName = "John"; lastName = "Doe" }
    { firstName = "Jane"; lastName = "Doe" } ]

let combineNames person =
    $"{person.firstName} {person.lastName}"

let names =
    people
    |> List.map combineNames

If you recall, in addition to having to annotate people in TypeScript, it also lost its ability to infer the type in the map function once it was given a name. Here all of this code is understood to be working on Person.