Frontend made fully type-safe and without null checks. Part 1

niko_doing_dev

Niko Borisov

Posted on August 11, 2024

Frontend made fully type-safe and without null checks. Part 1

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:

  1. Mostly imperative, because there is no predefined dsl for Promises chaining apart from then/catch
  2. 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:

  1. Customer is on the home page
  2. Customer clicks on "Basket" button
  3. Basket modal component is initialized, get basket request is sent
  4. Customer goes back to home page, get basket request is still not finished
  5. 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
  6. 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 };
}

Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }

}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
niko_doing_dev
Niko Borisov

Posted on August 11, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related