Frontend made fully type-safe and without null checks. Part 1
Niko Borisov
Posted on August 11, 2024
Introduction
I've seen many different approaches for reactivity in frontend applications and developed the ones that are most suitable for implementing features, type safe and without using null
checks inside application code. In this article I will give an overview fundamentals of modeling reactive applications and show example on how to implement type safe reactivity in Angular application. However, you can apply this in any framework using the same principles. We will start with very basic system analysis and domain modeling. Then I'll show how to use functional programming, type system and reactivity to implement very basic feature — toggling a light switch. By the end of this article you will understand how to think and what to do in what order in order to write reactive code. In next articles I will guide you through more complex real life user scenarios.
Popular approach and problems with it
Quite often when there is a need to call asynchronous operations developers use Promises. However, there are some issues with promises:
- Mostly imperative, because there is no predefined dsl for Promises chaining apart from then/catch
- Not being able to cancel the request without using AbortController. Since most of the devs don't use AbortController due to its clumsy look, most web apps are having memory leakages and bugs.
Example
There is online store, where basket is located in UI modal. Customer on online store wants to check its basket, but then quickly changes their mind and goes back.
What happens there:
- Customer is on the home page
- Customer clicks on "Basket" button
- Basket modal component is initialized, get basket request is sent
- Customer goes back to home page, get basket request is still not finished
- Now there is a leak in memory because Basket modal component was still pointed in terms of GC when it was supposed to be destroyed
- If there is a callback on promise then it will trigger undesired behavior: user already closed the modal, but sees actions regarding that modal
To solve this problem we can use RxJS and I will show how to avoid all the problems above.
Overview of reactivity (in terms of UI applications)
First we need to understand several terms that we will use later.
Stream
Stream is a sequence of values that we can listen to. When we listen to that sequence, we get new value every time stream produces new value. Basically anything that includes asynchronous operations is a stream.
Examples:
Listening to websocket, where every message coming from server would be a value in a stream and on connection drop it would throw an error. Executing http request, where every response would be a value in a stream and on error it would throw an error.
User actions
Something that user does with UI
Examples:
Click on a button, typing in text field, scrolling.
Command
Command is an action that can be invoked by user. In some cases commands can be invoked automatically by certain system events.
Examples:
Http request submit, sending message to websocket.
Event
Event is a system event. When user invokes a command, some pipeline is triggered, that pipeline emits some events.
Examples:
Http request completed, websocket message received.
Http request failed, websocket disconnected.
State
State is a value that represents the current value of some aggregation.
Everything starts from an initial state. And then it is changed by applying reducer with new events or commands.
Examples:
Http request completed/in progress/failed, websocket connected/disconnected.
Reducer
Reducer reacts to commands and events and produces new state.
Examples:
When http request is executed by user, state will become in progress and when it is completed, state will become completed.
Pipeline (or flow)
Pipeline is a sequence of operations that are executed in order one by one.
Pipelines handle commands invoked by user, execute some side effects and emit events in the system.
Examples:
When http request is executed by user, pipeline will get started that actually triggers http api call and then handles response, catches errors and emits according events.
Finite state machine
Finite state machine is a thing that contains determined set of states and transitions between them. Basically there are rules that machine can transit between states only if certain conditions are met.
Plugin (not related to reactivity)
This relates to composable architecture. Plugins are atomic entities that can handle certain parts of logic in our app. Plugin can be constructed dynamically from config, it accepts commands, emits events and updates its state. You can also think about plugin as an actor. However, there is no need for real actors on frontend, so I prefer term "plugin". Basically every plugin is a composable FSM.
Examples:
Http plugin, it accepts config for request, sends http request when user invokes a command and emits events when response is received or error occurs.
Overview of domain modeling and system analysis
As a developer you need to understand essentials how to express real world entities and rules in your application code. To do that you need to understand essentials of domain modeling and system analysis. Domain modeling is the process of mapping the real world entities and their relationships to some terms that are used in both the communication and the code. System analysis is the process when you research and document what are use-cases of the application, what scenarios can be there, what business rules are, what constraints are. Basically you need to research and to document everything from business goals to feature test cases.
Implementation of reactive architecture using RxJS and Typescript
Let's define essential types. Doing that helps us to break down the whole system and simplify its implementation.
How to express application logic in typescript and what is programming about: our program is basically a set of possible scenarios that can happen in certain conditions. With that in mind we can unify common scenarios like working with data, handling errors, reacting on user actions, integrating with external apis and so on.
To implement proper abstractions let's break down application flow in real world. There are some actions that user can run. There is a UI that handles user actions and sends requests to some system like backend that processes requests and responds back either with success or error. If it's a read operation, then value can be either found or not. Basically that's what 90% of common applications consist of. For sure there are a lot of other things that can happen in the application, but in this article we'll focus only on the most essential scenarios.
Let's start with result types. Result means that some operation completed and it either run successfully or not. We can express that using these types:
// Ok means that operation was successful. In this case we can read value returned
export type Ok<T> = { kind: 'ok'; value: T };
// Failure means that operation failed. In this case we can read error, but can't read value, since there is no value
export type Failure<E> = { kind: 'failure'; error: E };
// Result can be either Ok or Failure
export type Result<T, E = any> = Ok<T> | Failure<E>;
// Bug means that something went wrong, that we didn't really expect.
// But instead of throwing an error, we return special kind of error that wraps bug with any data passed.
// This way we can structure it better in logs and not raise any errors in our application
export type Bug = { kind: 'bug'; data: any };
// These are just some helpers to make code more composable and easier to construct.
// Otherwise we would need to use explicit type casting, because typecsript is not always very smart
// { kind: 'ok', value: 1 } as Ok<number>
export function ok<T>(value: T): Ok<T> {
return { kind: 'ok', value };
}
export function failure<E>(error: E): Failure<E> {
return { kind: 'failure', error };
}
export function bug(data: any): Bug {
return { kind: 'bug', data };
}
If operation reads data from db, then we can suppose that data can present or not. We can define type for case when there is some data for case when there is no data and we can name it accordingly:
// Some means that there is some data
export type Some<T> = { kind: 'some'; value: T };
// None means that there is no data
export type None = { kind: 'none' };
// Option can be either Some or None
// Example:
// const player: Option<Player> = getPlayerFromDb(id)
export type Option<T> = Some<T> | None;
export function some<T>(value: T): Some<T> {
return { kind: 'some', value };
}
export function none(): None {
return { kind: 'none' };
}
// Easy to use wrapper
export function option<T>(value: T | undefined): Option<T> {
if (value === null || value === undefined) {
return none();
} else {
return some(value);
}
}
Now let's define types for plugins and reactivity. This is going to be a bit more complicated.
We need to generalize what can happen in the system. Basically there are either users who interact with the system, and there is also system itself that produces events reacting on different triggers, such as user actions or other events.
type PluginCommand = {
kind: string;
};
type PluginEvent = {
kind: string;
};
// naming those above starting with "Plugin" has a reason: it makes it easier for your IDE to import because words like Command or Event are way more likely to conflict with other libs (Event is included in dom lib).
// This is type that insures proper typing for commands, so that handlers can be implemented very easily
type Handlers<Command extends PluginCommand, Event extends PluginEvent> = {
[P in Command['kind']]: (
command: Extract<Command, { kind: P }>,
) => Observable<Event>;
};
// If we have some external data sources (listening to external APIs for example), we need to define them explicitly
type ExternalDataSources<T extends string, Event extends PluginEvent> = {
[P in T]: Observable<Event>;
};
// This is type of the function that will be called in reducer. It is proper typed: it accepts current state and event and returns new state
type PluginReducerFn<State, Event extends PluginEvent> = (
state: State,
event: Event,
) => State;
// This ensures that all the types for plugin passed properly and match all the requirements
type PluginConfig<
Command extends PluginCommand,
Event extends PluginEvent,
State,
InitialValue extends State,
Reducer extends PluginReducerFn<State, Command | Event>,
ExternalDataSource extends string = string,
> = {
handlers: Handlers<Command, Event>;
reducer: Reducer;
initialValue: InitialValue;
externalSources: ExternalDataSources<ExternalDataSource, Event>;
};
Don't be confused if there is not completely everything obvious with these types. They won't be used directly anywhere outside the plugin's code. I'll dedicate another article to typing with Typescript. Now after we have all the utility types we can define plugin that will be used in our system.
Plugin is a thing that can be constructed from a config, it accepts commands, and produces events. We can send a command using send
method. There is an event stream that allows us to listen to all system events. We need to subscribe to that event stream to initialize plugin, that's why it's called connect
. We can subscribe to current state using state
method. State is updated on each reducer call after command or event is received.
export interface AtomicPlugin<
Command extends PluginCommand,
Event extends PluginEvent,
State,
> {
connect(): Observable<Event>;
state(): Observable<State>;
send(command: Command): void;
}
Plugin implementation
// reactive-plugins.ts
export class AtomicPluginInstance<
Command extends PluginCommand, // possible commands to accept
Event extends PluginEvent, // possible events to emit
State, // type of state
InitialState extends State = State, // concrete type of the intial state
ExternalSources extends string = string, // list of keys of external sources. For example, 'statusUpdated' | 'onShutdown'
> implements AtomicPlugin<Command, Event, State> // pass generics to the interface in order to ensure proper types in implemented methods `send`, `connect` and `state`
{
protected config!: PluginConfig<
// same boilerplate as above =)
Command,
Event,
State,
InitialState,
PluginReducerFn<State, Command | Event>,
ExternalSources
>;
// accepts command from user
private command = new Subject<Command>();
// processes commands and emits events back to the stream
private commandHandler!: Observable<Event | never>;
// emits events from the system
private events!: Observable<Event>;
// emits current state
private stateSource!: Observable<State>;
constructor(
// just accepts config from higher level APIs
private configValue: PluginConfig<
Command,
Event,
State,
InitialState,
PluginReducerFn<State, Command | Event>,
ExternalSources
>
) {
this.config = configValue;
this.init();
}
// essential interface implementation. Could've been done through abstract base class
public connect(): Observable<Event> {
return this.events;
}
public state(): Observable<State> {
if (!this.stateSource) {
throw new Error('state source not defined');
}
return this.stateSource;
}
public send(command: Command): void {
this.command.next(command);
}
public init(): void {
// Here is how command handler works:
// It accepts command from user
// Then it gets handler from the config for that command
// - If there is no handler, then app is not configured properly, and we throw an error
// - If there is handler, then we call it with command, and return Observable of events.
// For now we use `mergeMap` here, because we need to make sure that all nested observables would complete if user sends multiple commands. This behavior can be extended later in order to handle different cases like http request canceling. But in this article we won't cover such scenarios.
this.commandHandler = this.command.pipe(
mergeMap((command) => {
const handler = this.config.handlers[command.kind as Command['kind']] as
| ((command: Command) => Observable<Event>)
| undefined;
if (!handler) {
throw new Error('No handler found');
}
return handler(command);
}),
share({ connector: () => new ReplaySubject(1) }),
);
// here we want to merge received events and external sources into one stream
const eventSources = merge(
this.commandHandler,
...(Object.values(this.config.externalSources) as Observable<Event>[])
);
this.events = eventSources.pipe(
share({ connector: () => new ReplaySubject(1) })
);
// This is our very simple state reducing pipeline
this.stateSource = merge(this.command, this.events).pipe(
// `scan` is just continous applying events over an accumulator state
scan<Command | Event, State, InitialState>((state, event) => {
return this.config.reducer(state, event);
}, this.config.initialValue),
// `startWith` accepts initial state
startWith(this.config.initialValue),
// `shareReplay` means make this observable hot. It will buffer the last emitted value and replay it to new subscribers.
// That means that if value was emitted before, but client subscribed later, it will be replayed to the client from buffer
shareReplay({ bufferSize: 1, refCount: true })
);
}
}
Basically that's it for the core implementation of the plugin. Now let's proceed to actual implementation of our light switch. We will display state from the plugin on the screen:
// light-switch.plugin.ts
import { AtomicPlugin, AtomicPluginInstance } from './reactive-plugins.ts';
import { EMPTY, Observable } from 'rxjs';
// We assume the only action user can do with a switch light is to toggle it
type ToggleCommand = {
kind: 'toggle';
};
export type LightSwitchCommand = | ToggleCommand;
// There is no real need for switch to return events, we can just subscribe to its state
export type LightSwitchEvent = never;
// Now we can see that light switch can have two states: on and off
type StateOn = {
kind: 'on';
}
type StateOff = {
kind: 'off';
}
// // define all possible states of light switch
export type LightSwitchState = StateOn | StateOff;
// Now let's create an implementation of our plugin which is basically simple state machine
// First we pass types to the plugin as generics
export class LightSwitchPlugin implements AtomicPlugin<LightSwitchCommand, LightSwitchEvent, LightSwitchState> {
private plugin: AtomicPlugin<LightSwitchCommand, LightSwitchEvent, LightSwitchState>;
constructor() {
// initialize actual plugin with config
this.plugin = new AtomicPluginInstance<LightSwitchCommand, LightSwitchEvent, LightSwitchState, StateOff>({
handlers: {
toggle: (cmd) => {
// no need to handle command actually
// `EMPTY` observable here is like a noop
return EMPTY;
}
},
reducer: (state, eventOrCommand) => {
// check what event or command is passed
switch (eventOrCommand.kind) {
case 'toggle':
// check current state
switch (state.kind) {
// if state is on and switch is toggled, then its state becomes off
case 'on':
return { kind: 'off' };
// if state is off and switch is toggled, then its state becomes on
case 'off':
return { kind: 'on' };
}
default:
throw new Error('Invalid event');
}
},
externalSources: {},
// switch is off on start
initialValue: { kind: 'off' }
});
}
// this is the required boilerplate
public connect(): Observable<LightSwitchEvent> {
return this.plugin.connect();
}
public state(): Observable<LightSwitchState> {
return this.plugin.state();
}
public send(command: LightSwitchCommand): void {
this.plugin.send(command);
}
}
Then let's initialize put light switches into our component somewhere. After setting up switches we need to aggregate state from all of them to display it on the screen. We'll combine observable and convert it to a signal.
// app.component.ts
// ...
export class AppComponent {
private lightSwitches = [
new LightSwitchPlugin(),
new LightSwitchPlugin(),
new LightSwitchPlugin()
];
private lightSwitchesState = toSignal(
combineLatest(
this.lightSwitches.map((lightSwitch) => lightSwitch.state())
)
.pipe(
map((states) => states.map((state) => state.kind === 'on')),
shareReplay({ bufferSize: 1, refCount: true })
),
{
requireSync: true
}
);
public lightSwitchesWithState = computed(() => this.lightSwitchesState().map((state, index) => ({
state,
instance: this.lightSwitches[index]
})));
constructor() {
// here we subscribe to light switches events stream to initialize plugins
merge(...this.lightSwitches.map((lightSwitch) => lightSwitch.connect())).pipe(
takeUntilDestroyed()
).subscribe();
}
}
Rendering in template
<!-- app.component.html -->
<div class="grid grid-cols-3 grid-rows-auto gap-4">
@for (item of lightSwitchesWithState(); track $index) {
<div class="h-24" (click)="item.instance.send({ kind: 'toggle' })"
[ngClass]="{ 'bg-yellow': item.state, 'bg-gray': !item.state }"></div>
}
</div>
Styles
/* styles.css */
.grid {
display: grid;
}
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-rows-auto {
grid-template-rows: auto;
}
.gap-4 {
gap: 1rem;
}
.bg-yellow {
background-color: yellow;
}
.bg-gray {
background-color: gray;
}
.h-24 {
height: 6rem;
}
Summary
In this article we've covered fundamentals how ot break down systems: every operation within the system returns result which is either success or failure. System consists of atomic plugins that accept commands from user, plugins can update their state and emit events.
To build a plugin we need to define commands, events, states, handlers for commands, reducer for state.
In next article we will extend functionality of our light switches by adding some real world scenarios where switch is broken and then being repared.
Posted on August 11, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.