Back to home

Resource Management in TypeScript

Published: Jul 15, 2024

Last updated: Jul 15, 2024

Update July 15th 2024: Based on some changes that I made to the `Result` type in a previous post, I've implemented changes here as well. The updated version will be it's own section at the.

Overview

In a previous post, we looked into how we could implement a Result type in TypeScript. This type was designed to represent either a success or a failure in a type-safe manner.

Building on top of that, we are next going to look at how we could use that Result type to implement the Acquire and Release Pattern in TypeScript.

Often, you may need to execute a series of linked operations where the success of each step relies on the success of the previous one. If any step fails, you will want to undo the effects of all the preceding successful steps. The Acquire and Release Pattern is a pattern that we can use to handle this situation.

This approach is heavily inspired by the approach that EffectTS takes for their approach to resource management.

Implementing the Acquire and Release Pattern

We can implement a version of the acquire and release pattern with the following helper function:

import { Result, ErrorType } from "./alt-result"; type AcquireRelease<A, E extends ErrorType, R = void> = { resource: A; release: () => Promise<Result<R, E>>; }; export class AcquireReleaseError { readonly _tag = "AcquireReleaseError"; readonly error: Error; constructor(error: Error) { this.error = error; } } /** * Acquires a resource and provides a function to release it. * * @template T The type of the resource to be acquired. * @template E The type of error that might occur during acquire or release (default: Error). * @template R The type of the result returned by the release function (default: unknown). * * @param acquire A function that returns a Promise resolving to a Result containing the resource or an error. * @param release A function that takes the acquired resource and returns a Promise resolving to a Result of the release operation. * * @returns A Promise resolving to a Result containing either an AcquireRelease object or an error. * * @remarks * The R type parameter represents the result type of the release operation. * It's set to 'unknown' by default, allowing the release operation to potentially return any type. * This can be useful if the release operation needs to return some specific information or status. * * Rejected promises are treated as defects and not handled in this function. * They are thrown as exceptions. */ export async function acquireRelease<T, E extends ErrorType, R = unknown>( acquire: () => Promise<Result<T, E>>, release: (resource: T) => Promise<Result<R, E>> ): Promise<Result<AcquireRelease<T, E, R>, E | AcquireReleaseError>> { try { const acquireResult = await acquire(); if (acquireResult.isFailure()) { return Result.fail(acquireResult.error); } const resource = acquireResult.data; return Result.succeed({ resource, release: () => release(resource), }); } catch (err) { return Result.fail( new AcquireReleaseError( err instanceof Error ? err : new Error(String(err)) ) ); } }

This function first attempts to "acquire" a resource by calling the acquire function.

If the acquisition is successful, it then returns an object containing the acquired resource and a release function that can be used to release the resource.

In the situation where the acquisition fails, the function returns the error that occurred during the acquisition.

Both success and failure situations makes use of our previously implement Result type.

Surfacing what we need to rollback

In the current approach I've taken, it returns the helper function to rollback, but it doesn't explicitly define how we rollback.

I've inverted that control to the caller to define how they want to rollback. We can see what I mean by looking at the following example that aims to create a few AWS resources and then gives us a way to rollback if any of them fail:

import { acquireRelease } from "./alt-acq"; import { ErrorType, Failure, Result } from "./alt-result"; // ... assume we have imported all the helper create and delete functions used below, as well as the custom errors // Step 1: Create user record const recordCreateResult = await acquireRelease( () => createUserRecord(userData), async (user) => { console.log(`Releasing user record`); return await deleteUserRecord(user.userId); } ); if (recordCreateResult.isFailure()) { return Result.fail(new RecordCreateError(recordCreateResult)); } const { resource: user, release: releaseUser } = recordCreateResult.data; console.log("User record created:", user); // Step 2: Create S3 bucket const s3BucketCreateResult = await acquireRelease( () => createS3Bucket(`user-${user.userId}-bucket`), (bucket) => { console.log(`Releasing S3 bucket`); return deleteS3Bucket(bucket.bucketName); } ); if (s3BucketCreateResult.isFailure()) { return Result.fail(new S3Error(s3BucketCreateResult, [releaseUser])); } const { resource: bucket, release: releaseBucket } = s3BucketCreateResult.data; console.log("S3 bucket created:", bucket); // Step 3: Upload record to S3 const uploadToS3Result = await acquireRelease( () => uploadRecordToS3(bucket.bucketName, { userId: user.userId, data: userData, }), (record) => { console.log(`Releasing S3 record due`); return deleteRecordFromS3(bucket.bucketName, record.recordId); } ); if (uploadToS3Result.isFailure()) { return Result.fail( new S3Error(uploadToS3Result, [releaseBucket, releaseUser]) ); } const { resource: record, release: rollbackUpload } = uploadToS3Result.data; console.log("Record uploaded to S3:", record); // Step 4: Send SQS message const sendSQSMessageResult = await acquireRelease( () => sendSQSMessage({ userId: user.userId, recordId: record.recordId }), () => Promise.resolve(Result.succeed(undefined)) // No specific cleanup for SQS ); if (sendSQSMessageResult.isFailure()) { return Result.fail( new SQSUploadError(sendSQSMessageResult, [ rollbackUpload, releaseBucket, releaseUser, ]) ); } const sqsResult = sendSQSMessageResult.data; console.log("SQS message sent successfully:", sqsResult); console.log("Process completed successfully"); return Result.succeed(undefined);

In the code I have above, I am relying on the result returned from the acquireRelease function to determine whether the operation was successful or not. If it was not, I am returning a Result.fail with the error and the rollback functions that I want to execute.

The alternative is that you could rollback directly within that block, but in the case that you have that logic encapsulated in a function, you can against surface things to the top-level to handle the rollback.

I am still thinking about this approach and whether it is the best way to go about it. You should consider whether this is the best approach for your use-case.

An example of surfacing the rollback logic to the top-level might look like this (assume the above code is in a function called processUserData):

import { S3Error, SQSUploadError, processUserData } from "./alt-main"; import { ErrorType, Result } from "./alt-result"; const unexpectedFailure = () => { throw new Error("Unexpected error"); }; // Mock functions (replace these with your actual implementations) const createUserRecord = (userData: { name: string; email: string }) => Promise.resolve(Result.succeed({ userId: "user123", ...userData })); const createS3Bucket = (bucketName: string) => Promise.resolve(Result.succeed({ bucketName })); const uploadRecordToS3 = (bucketName: string, data: any) => Promise.resolve(Result.succeed({ recordId: "record123" })); const sendSQSMessage = (message: any) => unexpectedFailure(); const deleteUserRecord = ( userId: string ): Promise<Result<{ message: string }, never>> => new Promise((resolve) => { setTimeout(() => { console.log("Deleted user record"); resolve( Result.succeed({ message: "User record deleted", }) ); }, 1000); }); const deleteS3Bucket = ( bucketName: string ): Promise<Result<{ message: string }, never>> => new Promise((resolve) => { setTimeout(() => { console.log("Deleted S3 bucket"); resolve( Result.succeed({ message: "S3 bucket deleted", }) ); }, 1000); }); const deleteRecordFromS3 = ( bucketName: string, recordId: string ): Promise<Result<{ message: string }, never>> => new Promise((resolve) => { setTimeout(() => { console.log("Deleted S3 record"); resolve( Result.succeed({ message: "S3 record deleted", }) ); }, 1000); }); async function main() { // Run the process const result = await processUserData( { name: "John Doe", email: "john@example.com" }, createUserRecord, createS3Bucket, uploadRecordToS3, sendSQSMessage, deleteRecordFromS3, deleteS3Bucket, deleteUserRecord ); if (result.isFailure()) { await result.error.rollback(); } } main();

In the above code, the functions that we inject will all succeed except for the sendSQSMessage function. When this function fails, we will surface the error to the top-level and then call the rollback function on the error to rollback the operations.

$ node example.ts # Successes start here User record created: { userId: 'user123', name: 'John Doe', email: 'john@example.com' } S3 bucket created: { bucketName: 'user-user123-bucket' } Record uploaded to S3: { recordId: 'record123' } # Oopsy! We failed, so the rollback has started Releasing S3 record due Releasing S3 bucket Releasing user record

As you can see, the rollback logic is executed in reverse order of the operations that were successful (thanks to how we defined them).

If we updated out uploadRecordToS3 error to also fail, we can see how our running our code changes:

// Before const uploadRecordToS3 = (bucketName: string, data: any) => Promise.resolve(Result.succeed({ recordId: "record123" })); // After const uploadRecordToS3 = (bucketName: string, data: any) => unexpectedFailure();

Now running our code:

$ node example.ts # Successes start here User record created: { userId: 'user123', name: 'John Doe', email: 'john@example.com' } S3 bucket created: { bucketName: 'user-user123-bucket' } # Oopsy! We failed, so the rollback has started Releasing S3 bucket Releasing user record

In the above, you'll noticed we've only rolled back the S3 bucket and user record.

Matching on the error type

If we want even more control around the rollback at the top-level, we could even make use of our custom errors and their tags.

Here is a list of what our custom errors from the code could look like:

export abstract class RollbackableError<E extends ErrorType> { readonly _tag: string; readonly reason: Failure<E>; private rollbackFns: Array<() => Promise<Result<unknown, E>>>; constructor( tag: string, reason: Failure<E>, rollbackFns: Array<() => Promise<Result<unknown, E>>> = [] ) { this._tag = tag; this.reason = reason; this.rollbackFns = rollbackFns; } async rollback() { for (const rollbackFn of this.rollbackFns) { await rollbackFn(); } } } export class RecordCreateError< E extends ErrorType > extends RollbackableError<E> { constructor( reason: Failure<E>, rollbackFns: Array<() => Promise<Result<unknown, E>>> = [] ) { super("RecordCreateError", reason, rollbackFns); } } export class S3Error<E extends ErrorType> extends RollbackableError<E> { constructor( reason: Failure<E>, rollbackFns: Array<() => Promise<Result<unknown, E>>> = [] ) { super("S3Error", reason, rollbackFns); } } export class SQSUploadError<E extends ErrorType> extends RollbackableError<E> { constructor( reason: Failure<E>, rollbackFns: Array<() => Promise<Result<unknown, E>>> = [] ) { super("SQSUploadError", reason, rollbackFns); } }

In the above example, I have an abstract class that could be used to define a custom error that has a rollback function. This function will execute all the rollback functions that were passed to the error.

Because of how the _tag is assigned, we could then use the matchTag logic that we defined in the previous post to determine what kind of error we are dealing with and then execute logic based on what went wrong.

// ... omit code from before async function main() { // Run the process const result = await processUserData( { name: "John Doe", email: "john@example.com" }, createUserRecord, createS3Bucket, uploadRecordToS3, sendSQSMessage, deleteRecordFromS3, deleteS3Bucket, deleteUserRecord ); await Result.matchTag(result, { Success: () => console.log("Success"), S3Error: (error: S3Error<ErrorType>) => console.error("S3Error", error), RecordCreateError: (error: RecordCreateError<ErrorType>) => console.error("RecordCreateError", error), SQSUploadError: async (error: SQSUploadError<ErrorType>) => { console.error("SQSUploadError", error); await error.rollback(); }, }); } main();

In the above logic, we are using the matchTag function to determine what kind of error we are dealing with. If it is an SQSUploadError, we are then calling the rollback function on the error to rollback the operations.

Updated approach (July 15th 2024)

After reviewing some types, I've simplified the Result type from the previous post and made changes to the acquireRelease function to make it a little more type smart:

import { Result } from "./alt-result"; type InferResultTypes<T> = T extends Result<infer A, infer E> ? [A, E] : never; type AcquireRelease<A, E, R = unknown> = { resource: A; release: () => Promise<Result<R, E>>; }; /** * Acquires a resource and provides a function to release it. * * @template T The type of the resource to be acquired. * @template E The type of error that might occur during acquire or release (default: Error). * @template R The type of the result returned by the release function (default: unknown). * * @param acquire A function that returns a Promise resolving to a Result containing the resource or an error. * @param release A function that takes the acquired resource and returns a Promise resolving to a Result of the release operation. * * @returns A Promise resolving to a Result containing either an AcquireRelease object or an error. * * @remarks * The R type parameter represents the result type of the release operation. * It's set to 'unknown' by default, allowing the release operation to potentially return any type. * This can be useful if the release operation needs to return some specific information or status. * * Rejected promises are treated as defects and not handled in this function. * They are thrown as exceptions. */ export async function acquireRelease< AcquireFn extends () => Promise<Result<any, any>>, ReleaseFn extends ( resource: InferResultTypes<Awaited<ReturnType<AcquireFn>>>[0] ) => Promise<Result<any, any>> >( acquire: AcquireFn, release: ReleaseFn ): Promise< Result< AcquireRelease< InferResultTypes<Awaited<ReturnType<AcquireFn>>>[0], InferResultTypes<Awaited<ReturnType<AcquireFn>>>[1], InferResultTypes<Awaited<ReturnType<ReleaseFn>>>[0] >, InferResultTypes<Awaited<ReturnType<AcquireFn>>>[1] > > { try { const acquireResult = await acquire(); if (acquireResult.isFailure()) { return acquireResult; } const resource = acquireResult.data; return Result.succeed({ resource, release: () => release(resource), }); } catch (error) { return Result.fail(error); } }

In this version:

  • I've added a InferResultTypes type that will infer the types of the Result type.
  • I've updated the AcquireRelease type to be more type smart.
  • I've updated the acquireRelease function to be more type smart.

These are still experimental and being battle tested, but I will post any updated changes as things happen.

Conclusion

Today's post looked at how we could implement the Acquire and Release Pattern in TypeScript in order to handle a sequence of operations and roll them back if need be.

We also looked at how our pattern can surface the control logic to the top-level which can be powerful for decision making.

In your own work, you could also extend this to optionally roll everything back in parallel or in a specific order. You could also extend this to handle more complex scenarios where you might want to roll back a specific operation and then retry it, or even retry the failing operation a number of times before opting to raise the error and rollback.

Resources and further reading

Disclaimer: This blog post used AI to generate the images used for the analogy.

Photo credit: robanderson72

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on Visibuild.

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.

Resource Management in TypeScript

Introduction

Share this post