Creating Transaction utility type in TypeScript

Performing asynchronous operations is a commonplace task within the web ecosystem, a task that is executed regularly on both the client and server sides. However, this often results in repetitive code, potentially leading to inconsistencies in API structure and codebase. This issue can be effectively addressed by employing a straightforward utility type written in TypeScript to bring about uniformity in handling asynchronous operations.

The Problem

Imagine you have several request functions on the client side, and you're managing your state as follows:

interface User {
  id: number;
  username: string;
}

interface UserState {
  loading: boolean;
  error: string;
  data: User;
}

interface UsersState {
  loading: boolean;
  error: string;
  data: User[];
}

There are several issues with this approach:

  1. Unnecessary Allocation of Flags: The current method involves the allocation of flags without a clear need, potentially leading to additional complexity and overhead.
  2. Lack of Compile-Time Type-Safety: The absence of compile-time type-safety allows unrestricted access to the data property at any point in the code. This can lead to runtime errors that could have been caught during compilation.
  3. Complexity of Flags: Working with flags introduces challenges compared to utilizing state variants with associated data. The latter provides a more structured and intuitive approach to handling different states.

Implementation

// Defining a flexible transaction type that can represent different states during an operation.
// It includes an optional data property and an optional error property.
type Transaction<Data = undefined, ErrorObject = Error> =
  | { is: 'idle' }                  // Represents the initial state, indicating no ongoing operation.
  | { is: 'busy' }                  // Represents the state when the operation is in progress.
  | (Data extends undefined ? { is: 'ok' } : { is: 'ok'; data: Data })  // Represents a successful operation with optional data.
  | { is: 'fail'; error: ErrorObject };  // Represents a failed operation with an associated error.

We've employed the generic parameters Data and ErrorObject to define the structure for data and error handling. The data property represents the response from a backend endpoint, while the error corresponds to the shape of the returned error object.

Furthermore, we utilized a union of types to delineate four potential state shapes, each associated with distinct properties. This approach ensures a clear and structured representation of the different states a transaction can be in.

Default types of undefined for Data and Error were set for these generic parameters. This choice acknowledges scenarios where endpoint responses may lack data, and the typical structure of the Error parameter aligns with the native error object in JavaScript.

Finally, we employed a ternary operator to dynamically craft the type. If the generic parameter Data is provided, the resulting type includes the data property; otherwise, it defaults to a simple object with the shape { is: 'ok' }. This enhances flexibility while maintaining type safety in various scenarios.

Usage examples

// Defining a type contract for the User object.
interface User {
  id: number;
  username: string;
}

// Creating a concrete transaction type specifically for user creation operations.
type UserCreation = Transaction<User, Error>;

// Initializing a userCreation object in the idle state.
const userCreation = { is: 'idle' } as UserCreation;

// Not allowed ❌
// Attempting to access the 'username' property at compile time results in an error,
// as the transaction is in the 'idle' state and does not have data.
userCreation.data.username;

// Checking for the 'fail' state and accessing the error message is allowed.
if (userCreation.is === 'fail') {
  // Allowed ✅
  userCreation.error.message;
  // Not allowed ❌
  // Compile time error
  userCreation.data.username;
}

// Checking for the 'ok' state and accessing the 'username' property is allowed.
if (userCreation.is === 'ok') {
  // Allowed ✅
  userCreation.data.username;
}

// Case without data

// Creating a transaction type without specifying data and error types.
type Like = Transaction;

// Initializing a likeTransaction object in the idle state.
const likeTransaction = { is: 'idle' } as Like;

// Attempting to access the 'data' property at compile time results in an error,
// as the transaction is in the 'idle' state and does not have data.
if (likeTransaction.is === 'ok') {
  // Not allowed ❌
  // Compile time error
  likeTransaction.data;
}

Summary

As evident, we've eliminated unnecessary property allocations when they are not required. Moreover, TypeScript introduces additional checks, prompting us to inspect the type of the is property before accessing other properties inside objects. This approach significantly enhances the safety of our development process.

By incorporating these measures, we ensure a more robust and secure development environment. This not only results in optimized memory usage but also contributes to the creation of a more reliable and user-friendly experience. It's a balance of efficiency and safety that leads to a smoother development journey and a better end-user experience.