For each selection, I have pulled out code snippets. The full files that these snippets are from are provided below. All the code in the snippets is code that I claim, whether I wrote it myself or collaborated with someone else.
export type Result<TSuccess> =
| {
kind: "success";
value: TSuccess;
}
| {
kind: "failure";
msg: string;
};
export const success = <TSuccess>(value: TSuccess): Result<TSuccess> => ({
kind: "success",
value,
});
export const failure = (error: string): Result<never> => ({
kind: "failure",
msg: error,
});
export const map =
<TSuccess, TNewSuccess>(
fn: (value: TSuccess) => TNewSuccess
): ((result: Result<TSuccess>) => Result<TNewSuccess>) =>
(result) =>
result.kind === "success" ? success(fn(result.value)) : result;
export const bind =
<TSuccess, TNewSuccess>(
fn: (value: TSuccess) => Result<TNewSuccess>
): ((result: Result<TSuccess>) => Result<TNewSuccess>) =>
(result) =>
result.kind === "success" ? fn(result.value) : result;
This code snippet shows the most important parts of the Result
module. The Result
module consists of the Result
type and several utility functions that work with it. The Result
type represents the result of some action that can either succeed or fail. Examples of possible failures include a nonexistant database key, a duplicate database key, or a business rule being broken. The Result
type is a powerful concept that helps to embrace error handling rather than avoid it, and more accurately describe what is being accepted by and returned from our functions. It is used primarily in our API route handler functions to do "Railway-Oriented Programming." This term was popularized by the blog "F# For Fun and Profit," but is broadly applicable to APIs in any language. It is a way to define API routes solely in terms of the "happy path," and all errors are handled by the underlying Result
type and the ROP framework. In addition to our API routes, Result
is used in some of our utility functions. In particular, the YAML parser is a great use case for this concept.
The Result
type itself is a discriminated union - an either
type. It can take on one of two values: a success value with a generic type, or a failure with a string error message. The success
and failure
functions are convenience functions provided so that creating result values is easier and they are more explicitly readable. The map
and bind
functions are where it gets fun. These two functions are common concepts within the realm of functional programming. They are functions that transform other functions.
Let's start with this. We can think of Result
as a "wrapper" for a normal value. We can imagine that there exist functions that turn normal values into other normal values (e.g. int -> int
). We can also imagine that there exist functions that take normal values and return a wrapper (e.g. int -> Result
). You would never want to write a function that accepts a Result
. Think about it: in every function like this, you'd have to check if the Result
is a success and actually has a value, and bypass the rest of the function if that is not the case. So, we have two types of functions: functions that start in "normal world" and move to "normal world," and functions that start in "normal world" and end in "wrapper world." But of course, you have all these functions that don't accept Result
values and you want to use them with Result
values. That's where map
and bind
come in.
Both map
and bind
take in a function as a parameter. Both map
and bind
transform the input function so that the new function is one that starts in wrapper world and ends in wrapper world. The only difference is this: map
transforms normal -> normal
functions, and bind
transforms normal -> wrapper
functions. So, with both map
and bind
, you can transform any function into one that operates within wrapper world.
The Result module demonstrates good software engineering principles in several ways. First, it embraces error handling by providing a type that can represent both successful and failed results. This allows developers to explicitly handle errors and avoid common pitfalls such as null or undefined values. Second, the Result type is used in conjunction with the “Railway-Oriented Programming” approach to define API routes solely in terms of the “happy path,” with all errors being handled by the underlying Result type and the ROP framework. This approach promotes code readability and maintainability by separating error handling from the main logic of the application. Finally, the map and bind functions provide a way to transform functions so that they can operate within the “wrapper world” of the Result type, promoting code reuse and composability.
export const createSession =
(
crud: AsyncCrudAPI<Session>,
io: Server,
codesRepo: AsyncUniqueValueStoreAPI<string>
) =>
async (req: Request, res: Response) => {
const socketId = getHeader(req, "X-Socket-ID");
const socket = bind(getSocket(io))(socketId);
const socketNotInRoom = bind(ensureNotAlreadyInRoom)(socket);
const newSession = bindAsync<Socket, Session>(async (socket: Socket) => {
const newSession = await createEmptySession(codesRepo, socket.id);
const created = await crud.create(newSession.code, newSession);
return created;
})(socketNotInRoom);
const joinedSession = map(([socket, session]: [Socket, Session]) => {
socket.join(session.code);
return session;
})(combine(socketNotInRoom, await newSession));
return process(
joinedSession,
(newSession) => res.status(200).json(sessionToSessionForHost(newSession)),
(errorMsg) => res.status(400).send(errorMsg)
);
};
This code snippet is one of our many route handler functions. These functions are what get called when someone hits an API route, and as such, are essential to the codebase.
The example shown is the route handler for creating a new session. There are two things to note about the code. First, createSession
is a curried function. At the basic level, it's a function that returns another function. The "outer" function accepts three parameters: crud
(the crud accessor functions), io
(the socket.io server object), and codesRepo
(the repository for managing session codes). This "outer" function returns an "inner" function that becomes the actual route handler function that express requires. It accepts the standard req
and res
parameters. The benefit of doing it this way is that we can pass in the necessary dependencies (crud
, io
, and codesRepo
) as parameters using dependency injection, and still have a function that works with what express expects. Since we do dependency injection, it allows us to easily create mock versions of the dependencies for testing.
Second, the function is using the functions from the Result
module and Railway-Oriented Programming. Each step of the "pipeline" that needs to happen when this route is called is a const
variable of the Result
type. Each step takes in the things that it depends on. All errors are properly handled by the Result
module functions. As you can see, the code is only concerned with the "happy path" until the very end, when we handle any possible error that came up in the pipeline.
This code snippet demonstrates several software engineering principles. It uses currying and dependency injection to make the code more modular and testable. By passing in dependencies as parameters, it allows for easy creation of mock versions for testing. It also uses Railway-Oriented Programming and the Result module to handle errors in a structured and organized way. This makes the code more readable and maintainable by separating the “happy path” from error handling. Overall, this code demonstrates good software engineering practices by being modular, testable, readable, and maintainable.
type Store<T> = {
data: Set<T>;
mutex: Mutex;
};
export const createStore = <T>(): Store<T> => ({
data: new Set(),
mutex: new Mutex(),
});
export const create = async <T>(
store: Store<T>,
tryGenValue: () => T
): Promise<T> => {
const release = await store.mutex.acquire();
try {
let value = tryGenValue();
while (store.data.has(value)) {
value = tryGenValue();
}
store.data.add(value);
return value;
} finally {
release();
}
};
export const release = async <T>(
store: Store<T>,
value: T
): Promise<Result<T>> => {
const release = await store.mutex.acquire();
try {
if (!store.data.has(value)) {
return failure("Value does not exist");
}
store.data.delete(value);
return success(value);
} finally {
release();
}
};
export const createAPI = <T>(
store: Store<T>,
tryGenValue: () => T
): AsyncUniqueValueStoreAPI<T> => ({
create: () => create(store, tryGenValue),
release: (value) => release(store, value),
});
This is the set store module. At a high level, it is an in-memory data store that stores unique values and has some basic operations on it. You can add new values to the store using the create
function. This function also ensures that the new value you add is unique. You can then "release" values from the store, allowing them to be reused, using the release
function. This function returns a Result
value, which is a failure if the value to be released does not exist.
This set store is also asynchronous and safely handles concurrency (no race conditions). It uses a mutex object that is locked for every operation to ensure safety. This is necessary because this set store object is a shared value, and multiple API calls may attempt to use it simultaneously.
You'll also notice the function createAPI
. This function creates a nicer interface to use a set store. Since you'll likely be using a single Store
object throughout the app, it would be annoying to have to pass the store
parameter every time you wanted to use create
or release
. Instead, we can pre-apply that store
parameter and output two new create
and release
functions with the store
parameter "baked in." This also allows for a type AsyncUniqueValueStoreAPI
. This type represents an "interface" that can be implemented in any way. For example, you could implement the same interface by backing it with a database rather than an in-memory store.
In our app, this set store is used to manage session codes. Our session codes consist of 6 digits, where each is a capital letter or a digit. This means there are 36^6 possible codes, and a very low chance of collision. However, we chose to eliminate that possibility entirely. When creating new codes for new sessions, we could have pulled all the sessions from the session store and checked against their codes. However, that would have introduced extra complexity with regard to concurrency. So, we chose to use this independent concurrency-safe unique value store to manage creating and releasing session codes. This leads to a more decoupled and manageable codebase.
This code demonstrates several good software engineering principles. For example, it uses a modular design by separating the set store functionality into its own module with a clear interface. This makes the code easier to understand and maintain. Additionally, the use of a mutex to handle concurrency safely is an example of defensive programming, which helps prevent race conditions and other concurrency-related issues. The createAPI
function also demonstrates the principle of abstraction by providing a simpler interface for using the set store. Finally, the use of this set store to manage session codes in a decoupled and manageable way is an example of separation of concerns, which helps keep the codebase organized and maintainable.
export type FrontendDataModel =
| {
type: "host";
session: SessionForHost;
sessionStatus: "inProgress" | "ended";
timeRemaining: Option<number>;
questionQueue: Question[];
}
| {
type: "participant";
participant: Participant;
sessionCode: string;
participantState:
| {
type: "waitingForHost";
}
| {
type: "acceptingResponses";
question: Question;
latestResponse: Option<QuestionResponseForParticipant>;
timeRemaining: Option<number>;
}
| {
type: "showingCorrect";
question: Question;
latestResponse: Option<QuestionResponseForParticipant>;
}
| {
type: "sessionEnded";
};
}
| {
type: "noneSelected";
};
// sample of some of the actions
export type Action =
| {
type: "createSession";
newSession: SessionForHost;
}
| {
type: "endSession";
completedSession: SessionForHost;
}
| {
type: "newResponse";
response: QuestionResponseForHost;
}
| {
type: "newQuestion";
question: Question;
}
| {
type: "sentNewResponse";
response: QuestionResponseForParticipant;
};
// sample of part of the reducer
export const reducer = (
state: FrontendDataModel,
action: Action
): FrontendDataModel => {
switch (action.type) {
case "createSession":
if (state.type !== "noneSelected") {
logError("cannot create a session if in one already");
return state;
}
return {
type: "host",
timeRemaining: null,
session: action.newSession,
sessionStatus: "inProgress",
questionQueue: [],
};
case "endSession":
if (state.type !== "host") {
logError("cannot end a session if not the host");
return state;
}
if (state.session.activeQuestion !== null) {
logError("cannot end session if there is an active question");
return state;
}
return {
...state,
session: action.completedSession,
sessionStatus: "ended",
};
}
};
const [dataModel, dispatch] = React.useReducer(reducer, {
type: "noneSelected",
});
For our major frontend state management, we decided to use React's useReducer
hook (for smaller state things, we still used component-scoped useState
s). This hook is similar to useState
, but it is much more specific with how you're allowed to update the state. We first defined a state type, FrontendDataModel
, to represent what the state of our application would be. As you can see, the type is a discriminated union type that represents any possible state that the application can be in with regard to the user, session status, etc. Then, we defined the major actions that could be taken in our application as the Action
type. The code snippet shows a couple of actions we defined, such as creating a session and sending a new response. Finally, we define a reducer
function that accepts the current state and an action to carry out. The reducer
function then does the action by transforming the state and returning the updated state.
In this way, we can be very deliberate about what actions will happen in the application and how we handle the state when doing those actions. This makes it much more difficult to write buggy code that could put the app in an invalid state, and makes it much easier to manage the state as a whole.
After defining our state model, actions, and reducer function, we put it all in a React.useReducer
hook. We also put this hook into a react context so that it would be accessible from any component in the app. This made building out the frontend much simpler. We could completely decouple the state management from the views. So, once we had the state correct, we could build out any view just by hooking into the current state and building a view around it.
This code demonstrates several software engineering principles. One of the key principles demonstrated is the use of type safety through TypeScript. By defining the FrontendDataModel
and Action
types, the code ensures that the state and actions are well-defined and that any invalid states or actions are caught at compile-time. This helps to prevent runtime errors and makes the code more maintainable.
Another principle demonstrated is the separation of concerns. The state management logic is separated from the view logic through the use of a reducer function and the useReducer
hook. This makes it easier to reason about the state management and view logic independently, leading to more modular and maintainable code.
Finally, the use of a reducer function and useReducer
hook also demonstrates the principle of immutability. The state is never mutated directly, but rather a new state is returned by the reducer function based on the current state and action. This helps to prevent unintended side-effects and makes it easier to reason about how the state changes over time.