The Evolution of Asynchronous Programming in TypeScript

Asynchronous programming in JavaScript and TypeScript has come a long way. From the early days of callbacks to modern functional effects, each paradigm brought improvements in readability, error handling, and type safety. Let's explore this evolution.

The Callback Era: Foundations of Async

Callbacks were the original async pattern in JavaScript. Errors are passed as the first argument, and TypeScript adds type safety through parameters like err: Error | null.

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

The problems are clear:

  • Nested calls create unreadable "callback hell"
  • Manual error propagation required at every level
  • Hard to reason about control flow

The Promise Revolution: Chaining for Clarity

Promises brought method chaining with .then().catch(), dramatically improving readability. Combined with async/await syntax, code became almost synchronous-looking.

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) {
    if (err instanceof Error) {
      console.error('Operation failed:', err.message);
      throw err;
    }
    throw new Error('Unknown error occurred');
  }
}
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) {
    if (err instanceof Error) {
      console.error('Operation failed:', err.message);
      throw err;
    }
    throw new Error('Unknown error occurred');
  }
}

TypeScript integration improved with Promise<T> typing successful outcomes—but errors remain unknown. Other limitations include:

  • Unhandled rejections can crash applications
  • Eager execution makes cancellation difficult
  • No way to type the error channel

Effects: Typed, Lazy, and Composable

Effect-TS represents the next evolution. The Effect<A, E, R> type captures:

  • A — the success type
  • E — the error type (fully typed!)
  • R — required dependencies

Execution is deferred, enabling better control and composition.

import { Effect, pipe } from 'effect';

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 })))
  )
);
import { Effect, pipe } from 'effect';

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

Key benefits:

  • Every error type is explicit in the signature
  • Lazy execution enables cancellation and resource management
  • Functional composition similar to monads
  • The compiler enforces error handling

Comparison

ParadigmProsConsBest For
CallbacksExplicit, lightweightNesting, manual errorsEvents, simple APIs
PromisesReadable, chainableUnknown errors, eagerAPI fetches, general async
EffectsTyped errors, lazy, composableLearning curveComplex, error-prone systems

Conclusion

TypeScript's async evolution empowers developers with increasingly powerful tools. For projects prioritizing type-safe error management and complex async workflows, Effect-TS offers the most robust solution.

Want to learn more? Check out effect.website for comprehensive documentation and guides.