Chasten—a functional programming expressjs alternative
Chasten is an unabashedly opinionated little library for Node.js that provides a simple foundation upon which web applications can be built. It is a set of building blocks that help you write web applications in a style heavily inspired by and not altogether incongruent with functional programming despite the inherent obstacles presented by Node.js and JavaScript.
Chasten resides in the same general area as a number of more popular and proven NPM libraries, but it seems doubtful there can be any genuine rivalry. Most of the packages that Chasten ostensibly contends with are completely incompatible with pure functional programming and Chasten, in turn, makes no attempt to be compatible with them.
Why would anyone pick Chasten over Express et al, then? Because functional programming is fundamentally the right way to build web applications, and Chasten helps make it possible and even somewhat convenient to do so. See the rationale for a long-winded explanation of why functional programming fits web development so well.
Table of contents
Rationale
Despite a recent surge in conceptual popularity, functional programming in the Node.js ecosystem remains elusive and fundamentally unsupported by the major web frameworks. Sure, there are a number of utility libraries like ramda and sanctuary, but the fundamentals of serving HTTP requests are still left to mutation, statefulness, and other abject misery. What are those fundamentals, exactly?
- Taking a request and returning a response.
- Routing a request URL and method to a specific handler.
- Handling the effects produced by application code in a functional manner.
- Handling the co-effects of application code in a functional manner.
Some subset of these are sporadically supported by a few of the popular Node.js frameworks, but what if you want all of them? I couldn't find anything to help me out so I wrote Chasten.
What follows is a brief recap of why functional programming is preferable in general, and no attempt has been made to sugar-coat my strong opinions on the matter. If you find yourself disagreeing with much of the below, Chasten might not be a good match for you.
Prefer Functional Programming to OOP
Functional programming is a fundamentally good idea.
- Pure functions that take data and return data with referential transparency and no side effects are key to a maintainable program.
- Actions should be expressed by returning data that describe their intent rather than by performing them.
- Objects should be nothing but dictionaries of unchanging facts that pure functions can transform.
On the other hand, object-oriented programming is a major source of headache.
- Functions that mutate their arguments and cause observable side effects are fundamentally unpleasant to reason about, test, or compose with each other.
- Performing mutative actions like I/O or state changes all over your application code with no clear boundary between pure and impure sections sets you up for a terrible time when you need to test your code.
- The rich objects of the OOP world tend to result in programs where each object has its own internal, specific, one-off API and everything is stateful. This unerringly result in a reduced ability to compose the basic building blocks of applications, which should be simple data, because this data is hidden or inconveniently accessed due to data hiding.
When application code is just pure functions and dumb data, the need to reason about how the world changed in response to previous function calls disappears entirely; the world didn't change. This is a huge relief for test code authors, who no longer have to be careful what they effect by using the functions of the domain they're testing.
When there is no state, the mental gymnastics required to understand a section of code tend to abate in difficulty. This also means there is no state to mock in testing, lessening the testing hassle.
Simple is not always easy
Contrary to widespread belief, simple does not guarantee easy, and simple is more important than easy. If the two are in conflict, Chasten prefers to be simple by splitting interfaces into their constituent parts and sacrificing convenience. When separate concerns are decomplected like that, reasoning about the system becomes easier, and assembling the system precisely as desired becomes possible. This is always desirable, even if it means applications now have to put more pieces together to work. Chasten goes a long way to keep you in control.
- Routing in web applications often conflates the concepts of finding the destination (handler) of a request, producing a response to the request, and even effecting the sending of a response over the wire. The three are entirely separate concerns and should not be mashed into one. You can handle requests without taking a stance on routing and, vice versa, you can route HTTP requests without worrying about how to handle a request if you want.
- Requests and responses are completely separate entities and should be kept separate. They're not the same object, it doesn't make sense to operate on both simultaneously, and neither has anything to do with sending data over the wire.
- It must be possible to separate the decision to run impure code from the implementation of how to do it. Otherwise you end up with muddy purity boundaries.
No surprises
It should be possible to read the source code and correctly infer the behavior of the program. Surprises are usually bad surprises in the context of programming. "Magic", as some people take to call the weird things you never asked your program to do, but it did anyway, has no place in serious programming. A core tenet of a good code base is its readers' ability to know what it does - even after a year-long hiatus working on other code bases. These surprises tend to creep in with the best of intentions, and HTTP in particular has a great number of common practices and conventions that a web library tends to handle for you. Chastens chooses not to in the general case.
Conceptual overview
This is a conceptual introduction to Chasten and uses a lot of words on explanations. The alternative is the much terser API reference, which focuses on the public Node.js contract and might be more useful after you've learned the concepts.
Chasten is built on the fundamental idea that the typical HTTP request → response interaction is readily and intuitively modelled with functional programming: you write a function that takes a request as argument and returns a response, and there you are. The web server (Chasten + Node.js) will handle the business of actually sending the response over the network. All you should be concerned with is expressing your intent in pure data.
As you read this introduction, you'll come to realize that Chasten does almost nothing. It is mostly a pledge you undertake to follow certain conventions and abstain from functional impurity, and in so doing, Chasten and its fellow libraries have surprisingly little work to do, and you and your application code - in turn - will benefit in various ways.
Let's introduce some terminology.Handlers
A handler is precisely what was alluded to above, a function from (Request) → Response. Here is an example that responds, in JSON, with all the details of the request it was given:
function echoHandler(request) {
return {
'body': JSON.stringify(request),
'headers': {
'Content-Type': 'application/json',
},
'status': 200,
};
}
A handler must return an HTTP status and optionally headers and a body. How to send this information over the wire is left to Chasten and Node.js—not your application code. Middlewares
Often some logic must be applied to several different handlers, and while duplicate code in several handlers might do in the small, you likely want to create reusable middlewares for these chunks of logic for projects beyond a certain size. A middleware in Chasten is analogous to its counterparts in Express, Koa, et al, but the contract is different.
A middleware takes a handler and returns a (probably different) handler. This means you can wrap a handler in middleware to produce a new handler, which can be further wrapped in middleware. Each layer nests the previous stack of handler-and-possibly-middlewares inside a new middleware. If this seems wildly academic and confusing, perhaps a few examples will make the idea more tangible.
This middleware turns any response into a 204 No Content and removes the body.
function noContent(handler) {
return async(request) => {
const response = await handler(request);
return {
...response,
'status': 204,
'body': null,
};
}
}
Notice how it calls upon the handler it was given to let the chain of middlewares and finally the handler run their code.
This middleware enriches the request object with a user property which is then available to the handler and any middlewares running after this one.
function withUser(handler) {
return async(request) => {
const userRequest = {
...request,
'user':{
'name': 'Antediluvian Dev',
},
};
return handler(userRequest);
}
}
The two middlewares above operate at two different points in time in relation to the request → response cycle. The first waits for the handler to return a response, and then changes that response before returning it. The second changes the request before passing it on the handler, and then just returns whatever resulted from the handler.
These two approaches are not mutually exclusive; this next middleware adds some runtime data to both the request and the response.
Please note: the code below is impure. There are ways to make it pure, and you can read about those in the section about prithee.
function runtime(handler) {
return async(request) => {
const start = new Date();
const timedRequest = {
...request,
start,
};
const response = await handler(timedRequest);
const end = new Date();
const timedResponse = {
...response,
start,
end,
'duration': end - start,
};
return timedResponse;
}
}
In summary, a middleware takes a handler as input and returns a new handler. This new handler will most likely call upon the origin handler to turn the request into a response by unwinding the remainder of the middleware stack and the handler itself. Alternatively it can return its own response without calling the original handler to short-circuit the middleware stack. This is particularly useful when a request must be rejected, e.g. due to failed authentication. In this way, middlewares have the power to change both the request and response.
Here's a graphical illustration of a handler and several middlewares.
The request comes in
|
| The response goes out.
↓ ↑
+--------------+ Changing the request here will affect middleware 2
| Middleware 1 | and down. Changing the response will affect no
+--------------+ other middlewares or handlers.
↓ ↑
+--------------+ Changing the request here will affect midddleware 3
| Middleware 2 | and down. Changing the response will affect
+--------------+ only middleware 1.
↓ ↑
+--------------+ Changing the request here will affect only the
| Middleware 3 | handler. Changing the response will affect
+--------------+ middleware 2 and up.
↓ ↑
+--------------+ The request becomes a response and travels back up
| Handler | in reverse order through the middlewares.
+--------------+
Requests
A request is an object with the following keys.
body
A string containing the already-read request body stream. No parsing is applied.
headers
An object with header names as keys and their string values as values.
method
An upper-case String representing the HTTP method of the request.
url
A Node.js URL Object with the
origin
set tohttp://localhost:80
,auth
empty, and thepath
set to what's present in the incoming request.- remoteAddress
The string representation of the remote IP address. For example, "74.125.127.100" or "2001:4860:a005::68". Value may be undefined if the socket is destroyed (for example, if the client disconnected).
{
"body": "",
"headers": {
"host": "localhost:3000",
"user-agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:66.0) Gecko/20100101 Firefox/66.0",
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "en-US,en;q=0.5",
"accept-encoding": "gzip, deflate",
"dnt": "1",
"connection": "keep-alive",
"upgrade-insecure-requests": "1"
},
"method": "GET",
"remoteAddress": "::ffff:127.0.0.1",
"url": URL("http://localhost:80/pets?name=doge")
}
Responses
A response is an object with the following keys.
body
Either
undefined
,null
, a String, a Buffer, or a ReadableStream. This is what will be sent to the client making the request as an HTTP response body.headers
An object with header names as keys and their string values as values. These will be the headers of the HTTP response.
status
The HTTP status code for the HTTP response.
The response object might include any number of additional keys if necessary for the middlewares to work. These will not affect the HTTP response itself as Chasten only understand the concepts of a body, a status, and headers.
Example{
"body": "[2, 3, 5, 7, 11]",
"headers": {
"content-type": "application/json",
"cache-control": "max-age=3600"
},
"status": 200
}
API Reference
The NPM package @antediluvian/chasten
exports the following properties when imported.
requestListener
Makes your handler usable by Node.js.
Signature
(handler) → node.js request listener
Description
Returns a function that can be used with Node.js' http.createServer function to create a web server. It takes as sole argument a Chasten handler.
Example
const { createServer } = require('http');
const { requestListener } = require('@chasten/chasten');
function handler(request) {
return {
'body': 'Hello, world!',
'headers': { 'Content-Type': 'text/plain' },
'status': 200,
};
}
const server = createServer(requestListener(handler));
server.listen(3000);
wrap
Wraps a handler in zero or more middlewares.
Signature
(handler, [middleware]) → handler
Description
Takes a handler and an array with zero or more middlewares as arguments and returns a new handler. Given arguments handler
and [m1, m2, …]
, it will first call m1
, then m2
, then …
, and finally handler
.
Example
const { wrap } = require('@chasten/chasten');
async function handler(request) {
return {
'body': 'Hello, world!',
'headers': { 'Content-Type': 'text/plain' },
'status': 200,
};
}
async function upperCase(handler) {
return async(request) => {
const response = await handler(request);
return {
...response,
'body': response.body.toUpperCase(),
};
};
}
module.exports = wrap(handler, [upperCase]);