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 typeE— 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
| Paradigm | Pros | Cons | Best For |
|---|---|---|---|
| Callbacks | Explicit, lightweight | Nesting, manual errors | Events, simple APIs |
| Promises | Readable, chainable | Unknown errors, eager | API fetches, general async |
| Effects | Typed errors, lazy, composable | Learning curve | Complex, 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.