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 classrollback
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 postacquireRelease
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 theResult
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
Dennis O'Keeffe
Melbourne, Australia
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