The Evolution of Asynchronous Programming in TypeScript

3 min read

TLDR: We provide this article as a Jupyter Notebook to try with the Deno Kernel, or just a typescript file you can deno run directly. Download the zip file here.

Introduction

In the world of JavaScript and TypeScript, asynchronous programming is essential for handling non-blocking operations like API calls, file reads, or database queries. Over time, the approaches have evolved from raw callbacks to more sophisticated promises and, recently, to functional effects. This article explores this progression, focusing on TypeScript's type safety advantages, error handling challenges, and how modern libraries like Effect-TS are pushing boundaries. We'll cover mechanics, pros/cons, and practical advice, backed by examples and comparisons.

The Evolution of Asynchronous Programming in TypeScript: From Callbacks to Promises to Effects

The Callback Era: Foundations of Async

Callbacks were the go-to for async in early JavaScript. A function would take another function (the callback) to execute upon completion.

Key Features

  • Explicit Error Handling: Errors passed as first argument, e.g., (err, result) => {}.

  • Type Safety in TypeScript: Parameters like err: Error | null ensure compile-time checks.

Drawbacks

  • Callback Hell: Nested calls lead to unreadable code.

  • Manual Propagation: Errors must be handled at every level.

Example

// Callback hell example - imagine fetching user, then posts, then comments
function getUserData(userId: string, callback: (err: Error | null, result: any) => void) {
  fetchUser(userId, (err, user) => {
    if (err) return callback(err, null);
    fetchUserPosts(user.id, (err, posts) => {
      if (err) return callback(err, null);
      fetchPostComments(posts[0].id, (err, comments) => {
        if (err) return callback(err, null);
        processComments(comments, (err, processed) => {
          if (err) return callback(err, null);
          saveToDatabase(processed, (err, saved) => {
            if (err) return callback(err, null);
            callback(null, { user, posts, comments: saved });
          });
        });
      });
    });
  });
}

Despite issues, callbacks are still used for events.

The Promise Revolution: Chaining for Clarity

Promises, introduced in ES6, treat async results as objects that 'promise' a value.

Advantages

  • Chaining: .then().catch() flattens code.

  • Async/Await: Syntactic sugar for readability.

TypeScript Integration

  • Promise<T> types successes, but errors are unknown.

Example

// Same workflow as callback hell, but much cleaner with async/await
async function getUserData(userId: string): Promise<{ user: any; posts: any; comments: any }> {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchUserPosts(user.id);
    const comments = await fetchPostComments(posts[0].id);
    const processed = await processComments(comments);
    const saved = await saveToDatabase(processed);
    
    return { user, posts, comments: saved };
  } catch (err: unknown) {
    // Error type is unknown - need runtime checks
    if (err instanceof Error) {
      console.error('Operation failed:', err.message);
      throw err;
    }
    throw new Error('Unknown error occurred');
  }
}

Limitations

  • Unhandled Rejections: Can crash apps if not caught.

  • Eager Execution: Runs immediately, hard to control.

Promises improved flow but left error typing gaps.

Effects: Typed, Lazy, and Composable

Effect-TS brings functional effects with Effect<A, E, R>.

Core Benefits

  • Typed Errors: E specifies error types.

  • Laziness: Defers execution for better control.

  • Composability: Pipe operations like monads.

Example

// Same workflow with Effect-TS - fully typed errors and composable
import { Effect, pipe } from 'effect';
// Define typed errors
class UserNotFoundError extends Error { readonly _tag = 'UserNotFoundError' }
class PostsNotFoundError extends Error { readonly _tag = 'PostsNotFoundError' }
class CommentsError extends Error { readonly _tag = 'CommentsError' }
class ProcessingError extends Error { readonly _tag = 'ProcessingError' }
class DatabaseError extends Error { readonly _tag = 'DatabaseError' }
type AppError = UserNotFoundError | PostsNotFoundError | CommentsError | ProcessingError | DatabaseError;
const getUserData = (userId: string): Effect.Effect<
  { user: any; posts: any; comments: any },
  AppError,
  never
> => pipe(
  Effect.tryPromise({ try: () => fetchUser(userId), catch: () => new UserNotFoundError() }),
  Effect.flatMap(user => 
    Effect.tryPromise({ try: () => fetchUserPosts(user.id), catch: () => new PostsNotFoundError() })
      .pipe(Effect.map(posts => ({ user, posts })))
  ),
  Effect.flatMap(({ user, posts }) => 
    Effect.tryPromise({ try: () => fetchPostComments(posts[0].id), catch: () => new CommentsError() })
      .pipe(Effect.map(comments => ({ user, posts, comments })))
  ),
  Effect.flatMap(data => 
    Effect.tryPromise({ try: () => processComments(data.comments), catch: () => new ProcessingError() })
      .pipe(Effect.map(processed => ({ ...data, comments: processed })))
  ),
  Effect.flatMap(data => 
    Effect.tryPromise({ try: () => saveToDatabase(data.comments), catch: () => new DatabaseError() })
      .pipe(Effect.map(saved => ({ ...data, comments: saved })))
  )
);

This paradigm minimizes uncaught errors and enhances safety.

Comparison Table

Paradigm Pros Cons Ideal Use Cases
Callbacks Explicit, lightweight Nesting, manual errors Events, simple APIs
Promises Readable, chainable Unknown errors, eager API fetches, general async
Effects Typed errors, lazy, FP-style Learning curve, overhead Complex, error-prone systems

Conclusion

From callbacks' explicitness to promises' elegance and effects' robustness, TypeScript's async evolution empowers developers. For your next project, consider Effect-TS if type-safe errors are key. Dive deeper at Effect.website.