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 rundirectly. 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 | nullensure 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 areunknown.
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:
Especifies 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.