January 25, 2023 · fp and typescript

flixbox showcases a full-stack client/server web application interacting with the TheMovieDB API. It leverages the functional programming library fp-ts and its module ecosystem.

fp-ts brings typeclasses and higher kinded types, concepts from functional programming languages like Haskell and PureScript, to TypeScript. The entire flixbox application, including the server-side API, is built using libraries from the fp-ts module ecosystem.

Overview of the HTTP API

Requests and data formats

All flixbox API requests are HTTP GET requests, and responses are exclusively in JSON format. No authentication is required.

Errors

flixbox responds with appropriate HTTP status codes and errors for issues:

  • Validation error: Invalid user input.
  • Provider error: TMDb returned invalid data.
  • Not found: Requested resource unavailable.
  • Server error: Generic server-side failure.
  • Method error: Incorrect HTTP method.

Searching movies

GET /results?search_query=QUERY

Responds with a SearchResultSet object.

Retrieving a movie

GET /movie/ID

Responds with a Movie object.

GET /popular

Responds with a SearchResultSet object.

HTTP middleware architecture

The server API uses hyper-ts, an fp-ts port of Hyper, enforcing strict middleware composition through static type checking. It runs on Express but can integrate with other HTTP servers.

Hyper is modeled as a State monad, reading incoming requests and writing responses through Express. Instead of directly mutating the connection, it produces a list of actions to execute in order.

The example below showcases the pipeline for handling /movie/ID requests, caching results from TMDb.

When /movie/3423 is called:

  • Checks the internal cache.
    • If cached, returns the cached value.
    • Otherwise fetches data from TMDb caches it, and returns the result.
  • Responds with a JSON object.

server/Flixbox.ts

pipe(
  GET,
  // continue if this is a GET request only
  H.apSecond(
    pipe(
      // retrieve the requested entry from cache
      get(store, `/movies/${String(route.id)}`),
      H.map(entry => entry.value),
      // if not exists, fetch from TMDb
      H.orElse(() =>
        pipe(
          movie(tmdb, route.id),
          H.chain(value =>
            pipe(
              // insert the TMDb response to cache
              put(store, `/movies/${String(route.id)}`, value),
              H.map(entry => entry.value)
            )
          )
        )
      )
    )
  ),
  // write JSON response
  H.ichain(res =>
    pipe(
      H.status<AppError>(200),
      H.ichain(() => sendJSON(res))
    )
  )
)

The apSecond function executes only if the preceding GET middleware succeeds, while orElse handles failures. The main pipeline short-circuits on AppError.

The GET middleware

middleware/Method.ts

import { right, left } from 'fp-ts/lib/Either'
import { StatusOpen } from 'hyper-ts'
import { decodeMethod, Middleware } from 'hyper-ts/lib/Middleware'
import { MethodError, AppError } from '../Error'

function method<T>(name: string): Middleware<StatusOpen, StatusOpen, AppError, T> {
  const lowercaseName = name.toLowerCase()
  const uppercaseName = name.toUpperCase()
  return decodeMethod(s =>
    s.toLowerCase() === lowercaseName
      ? right<AppError, T>(uppercaseName as T)
      : left(MethodError)
  )
}

export const GET = method<'GET'>('GET')

The method middleware compares the request method to the provided one, returning it in uppercase if matched, otherwise throwing a MethodError (an AppError subtype). It can only be composed before writing headers or responses.

All core middlewares return AppError:

  • get: Returns NotFoundError if an entry is not found.
  • put: Returns ServerError on save faillures.
  • movie: Handles TMDb API errors as ProviderError.

An orElse handler can be added to manage AppError, sending appropriate error messages or logging issues. The destroy middleware handles this.

Logging

flixbox utilizes the lightweight and composable logging module, logging-ts. This library, adapted from purescripting-logging, integrates seamlessly with hyper-ts using the TaskEither type.

Runtime type system

io-ts is crucial for type validation throughout the application:

There are many type validation libraries available in the JavaScript community. However, the libraries developed by Giulio Canti, including io-ts and its predecessor tcomb, have gained significant popularity and adoption.

Many other libraries suffer from design flaws that hinder type inference. Type composition techniques aren't inventions, but rather discoveries made decades ago. io-ts excels by effectively implementing these established principles.

Optics —i.e. immutable state updates

monocle-ts is a porting of Monocle from Scala, offering type-safe and composable state manipulation.

In simpler terms, optics allow you to create structures (like Lens compositions) that focus on specific parts of your data. You can then transform or read values within that targeted area without modifying the original data.

Immer.js offers a similar functionality, relying on a produce function that creates a copy of the object before making changes.

import produce from "immer"

// curried producer:
const toggleTodo = produce((draft, id) => {
    const todo = draft.find(todo => todo.id === id)
    todo.done = !todo.done
})

const nextState = toggleTodo(baseState, "Immer")

You can achieve the same result using Traversal:

import * as _ from 'monocle-ts/lib/Traversal'

type T = { id: number; done: boolean }

type S = ReadonlyArray<T>

const getNextState = (id: number) =>
  pipe(
    _.id<S>(),
    _.findFirst(n => n.id === id),
    _.prop('done'),
    _.modify(done => !done)
  )

const nextState = getNextState(42)(baseState)

Routing

This application synchronizes the URL in the address bar with the content displayed in the flixbox window. You can directly access a specific page by providing an initial route in the URL. (Example)

Both client and server use fp-ts-routing for parsing request routes, integrating well with io-ts.

import * as t from 'io-ts'
import { lit, query, int, zero } from 'fp-ts-routing'

// popular matches /popular.
const popular = lit('popular')

// movie matches /movie/ID.
const movie = lit('movie').then(int('id'))

// SearchQuery is an io-ts type for matching the query part of a URL.
const SearchQuery = t.interface({
  search_query: t.union([t.string, t.undefined]),
})

// results matches /results?search_query=WORD.
const results = lit('results').then(query(SearchQuery))

Elm in TypeScript

flixbox UI builds on concepts from Elm, a language specifically designed for building graphical user interfaces (GUIs). elm-ts, which leverages RxJS, provides a TypeScript adaptation using fp-ts.

While elm-ts bears a surface-level resemblance to Elm, they function quite differently under the hood. Additionally, Elm employs the Hindley-Milner type system, which differs significantly from TypeScript's type system.

In flixbox, application state is managed through messages and an update function—similar to Redux but with a more functional approach. This cyclical pattern significantly simplifies state management, making testing and debugging (including time-travel debugging) much more straightforward.

For those interested in exploring FRP further, here are some resources:

Elm's design shares similarities with Redux. Messages in Elm are comparable to actions in Redux, and Elm's update function aligns with Redux's reducer function, both facilitating predictable state changes in applications.

src/app/Msg.ts

type Msg =
  | Navigate
  | PushUrl
  | UpdateSearchTerm
  | SubmitSearch
  | SetHttpError
  | SetNotification
  | SetSearchResults
  | SetPopularResults
  | SetMovie

The flixbox update cycle

  • Initial State: You define an initial application state.
  • View Function: A pure view function renders visual elements based on the current state.
  • Update Function: When user interaction triggers a message (e.g., clicking a link triggers a Navigate message), the update function is called.
    • It receives the message and the current state as inputs.
    • It transforms the state and potentially returns a new message.
  • State Updates: The new state is sent to subscribers like the view function.
  • Continuous Processing: New actions are processed until no further actions remain.