This post outlines the architecture and key components of flixbox, a full-stack 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 are defined like get
, put
, movie
, and results
for interacting with the TMDb API and managing caching.
Middleware architecture
API endpoints are structured as middleware pipelines that can short-circuit on failure. Each middleware performs a specific task and is responsible for handling failures within that scope, including validation of user input, provider errors when TMDb returns bad data, not found for missing resources, and so on.
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
.
Cross-stack 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
How it works:
- 📄 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)