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 movies
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.
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
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
: ReturnsNotFoundError
if an entry is not found.put
: ReturnsServerError
on save faillures.movie
: Handles TMDb API errors asProviderError
.
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:
- Defining client application state
- Modeling TMDb data
- Reporting validation errors
- Matching queries
- Validating React props
- Validating environment variables
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:
- Functional Reactive Programming
- Elm paper by Evan Czaplicki
- Push-pull FRP in PureScript using purescript-behaviors
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.
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.