Building a full-stack web app with fp-ts

  • January 25, 2023
  • starter and typescript

Architecture and key components of flixbox, a client/server web app built exclusively with fp-ts and its ecosystem of composable modules.

Server

The flixbox server is powered by hyper-ts, which is a partial porting of Hyper.

Internally, a set of middlewares is defined like get, put, movie, and results for interacting with the TMDb API and managing caching. These functions are arranged into pipelines that can short-circuit on failure, handling things like input validation, TMDb errors, or missing resources.

Example: Movie middleware

The following middleware handles /movie/ID requests.

When /movie/3423 is called:

  • 🗂️ Check the internal cache:
    • ✅ Return cached data if available.
    • 🔄 Otherwise, fetch data from TMDb, store it in the cache, and return the result.
  • 📦 Respond with a JSON object.
import * as H from 'hyper-ts/lib/Middleware'

// ...

function getMovieMiddleware(
  tmdb: TMDb,
  store: Storage<Document>
): (route: MovieRoute) => H.Middleware<StatusOpen, ResponseEnded, AppError, void> {
  return route =>
    pipe(
      GET, // ensure this is a GET request
      H.apSecond(
        pipe(
          // try to get the movie from the cache
          get(store, `/movies/${String(route.id)}`),
          H.map(entry => entry.value),
          // if not found, fetch from TMDb and cache it
          H.orElse(() =>
            pipe(
              movie(tmdb, route.id),
              H.chain(value =>
                pipe(
                  put(store, `/movies/${String(route.id)}`, value),
                  H.map(entry => entry.value)
                )
              )
            )
          )
        )
      ),
      // respond with JSON
      H.ichain(res =>
        pipe(
          H.status<AppError>(200),
          H.ichain(() => sendJSON(res))
        )
      )
    )
}

📄 See the full implementation in server/Flixbox.ts.


Shared modules

On both client and server, flixbox utilizes a set of common modules including:

  • logging-ts for structured logging
  • io-ts for runtime type validation
  • monocle-ts for optics support
  • fp-ts-routing for declarative route parsing

Among these, io-ts is especially valuable for robust type validation throughout the application, with applications such as:

  • Defining client-side application state
  • Modeling TMDb API data
  • Reporting validation errors
  • Matching URL queries
  • Validating React component props
  • Ensuring correctness of environment variables

io-ts is highly recommended even for projects that do not fully adopt fp-ts.

Extending fp-ts modules to new effect types

While these modules integrate smoothly within the fp-ts v2 ecosystem, certain scenarios may require explicit type-level configuration.

For instance, when using logging-ts with a custom effect type, you must provide an instance of the Logger algebra that conforms to that effect. logging-ts facilitates this by exposing getLoggerM, which abstracts over any monad.

To integrate logging-ts with the effects flixbox generates, a new HKT LoggerTaskEither is defined and registered in fp-ts's URItoKind2, thereby allowing type class instance support for logging within the TaskEither context, the most frequently used effect type in the project.


Client

The client uses elm-ts, which provides an fp-ts adaptation of Elm.

Elm shares conceptual similarities with Redux. Messages in Elm correspond to Redux actions, and the Elm update function closely mirrors Redux reducers, responsible for state changes.

Here are the message types used in the flixbox UI:

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

Elm architecture in a nutshell

  • 📄 Initial state: You define an initial application state, the model.
  • 🖼️ View function: A 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 its inputs.
    • ⚙️ It processes the current state and returns a new state and potentially 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.

📄 See the full implementation in app/Effect.ts.

Optics for immutable state management

The client also uses monocle-ts, a port of Monocle, allowing composable structures like Lens and Traversal for state updates without mutations.

Consider the following comparison to Immer.js:

Immer.js example:

import produce from "immer"

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

const nextState = toggleTodo(baseState, "Immer")

monocle-ts equivalent:

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

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

type Todos = ReadonlyArray<Todo>

const toggleTodoDone = (id: number) =>
  pipe(
    _.id<Todos>(),
    _.findFirst(todo => todo.id === id),
    _.prop('done'),
    _.modify(done => !done)
  )

const nextState = toggleTodoDone(42)(baseState)