When building production-grade TypeScript applications, basic Promise-based code often falls short. This article explores progressive improvements to data fetching, evolving from straightforward implementations to robust, type-safe solutions.
The Initial Approach
Let's start with a common pattern: fetching user data and their posts from an API.
interface User {
id: number;
name: string;
// other properties
}
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
let user: User;
let posts: Post[];
// Let's find the user with ID 1
user = await fetch("https://jsonplaceholder.typicode.com/users")
.then((r) => r.json())
.then((us) => us.find((u) => u.id === 1));
console.log("user", user.id, user.name);
// Let's get posts which belong to user
posts = await fetch("https://jsonplaceholder.typicode.com/posts")
.then((r) => r.json())
.then((ps) => ps.filter((p) => p.userId === user.id));
console.log("posts", posts.length);interface User {
id: number;
name: string;
// other properties
}
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
let user: User;
let posts: Post[];
// Let's find the user with ID 1
user = await fetch("https://jsonplaceholder.typicode.com/users")
.then((r) => r.json())
.then((us) => us.find((u) => u.id === 1));
console.log("user", user.id, user.name);
// Let's get posts which belong to user
posts = await fetch("https://jsonplaceholder.typicode.com/posts")
.then((r) => r.json())
.then((ps) => ps.filter((p) => p.userId === user.id));
console.log("posts", posts.length);This works, but has several issues:
- Type safety:
r.json()returnsany, bypassing TypeScript's checks - No error handling: Network failures crash the application
- No retry mechanism: Temporary issues cause permanent failures
- No timeout protection: Requests can hang indefinitely
Adding Error Handling
The first improvement is wrapping our fetches in try-catch blocks:
try {
user = await fetch("https://jsonplaceholder.typicode.com/users")
.then((r) => r.json())
.then((us) => us.find((u) => u.id === 1))
.then((u: User | undefined) => {
if (!u) throw new Error("User with ID 1 not found");
return u;
});
console.log("user", user.id, user.name);
} catch (error) {
console.error("Error fetching user:", error);
}
// Fetching posts for the user
try {
posts = await fetch("https://jsonplaceholder.typicode.com/posts")
.then((r) => r.json())
.then((ps) => ps.filter((p: Post) => p.userId === user.id))
.then((ps: Post[]) => {
if (ps.length === 0) throw new Error("No posts found for this user");
return ps;
});
console.log("posts", posts.length);
} catch (error) {
console.error("Error fetching posts:", error);
}try {
user = await fetch("https://jsonplaceholder.typicode.com/users")
.then((r) => r.json())
.then((us) => us.find((u) => u.id === 1))
.then((u: User | undefined) => {
if (!u) throw new Error("User with ID 1 not found");
return u;
});
console.log("user", user.id, user.name);
} catch (error) {
console.error("Error fetching user:", error);
}
// Fetching posts for the user
try {
posts = await fetch("https://jsonplaceholder.typicode.com/posts")
.then((r) => r.json())
.then((ps) => ps.filter((p: Post) => p.userId === user.id))
.then((ps: Post[]) => {
if (ps.length === 0) throw new Error("No posts found for this user");
return ps;
});
console.log("posts", posts.length);
} catch (error) {
console.error("Error fetching posts:", error);
}Better, but we're still missing retry logic for transient failures.
Implementing Retries
A retry mechanism handles temporary network issues gracefully:
const MAX_RETRIES = 3;
const RETRY_DELAY = 2000; // 2 seconds
async function fetchWithRetry<T>(
url: string,
numRetries: number = MAX_RETRIES,
): Promise<T> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
return await response.json();
} catch (error) {
if (numRetries > 0) {
console.warn(
`Error fetching ${url}: ${error.message}. Retrying in ${
RETRY_DELAY / 1000
} seconds...`,
);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
return await fetchWithRetry(url, numRetries - 1);
} else {
throw error;
}
}
}
// Let's find the user with ID 1
try {
const userData = await fetchWithRetry<User[]>(
"https://jsonplaceholder.typicode.com/users",
);
user = userData.find((u) => u.id === 1)!;
console.log("user", user.id, user.name);
} catch (error) {
console.error("Error fetching user:", error);
}
// Let's get posts which belong to user
try {
const allPosts = await fetchWithRetry<Post[]>(
"https://jsonplaceholder.typicode.com/posts",
);
posts = allPosts.filter((p) => p.userId === user.id);
console.log("posts", posts.length);
} catch (error) {
console.error("Error fetching posts:", error);
}const MAX_RETRIES = 3;
const RETRY_DELAY = 2000; // 2 seconds
async function fetchWithRetry<T>(
url: string,
numRetries: number = MAX_RETRIES,
): Promise<T> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
return await response.json();
} catch (error) {
if (numRetries > 0) {
console.warn(
`Error fetching ${url}: ${error.message}. Retrying in ${
RETRY_DELAY / 1000
} seconds...`,
);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
return await fetchWithRetry(url, numRetries - 1);
} else {
throw error;
}
}
}
// Let's find the user with ID 1
try {
const userData = await fetchWithRetry<User[]>(
"https://jsonplaceholder.typicode.com/users",
);
user = userData.find((u) => u.id === 1)!;
console.log("user", user.id, user.name);
} catch (error) {
console.error("Error fetching user:", error);
}
// Let's get posts which belong to user
try {
const allPosts = await fetchWithRetry<Post[]>(
"https://jsonplaceholder.typicode.com/posts",
);
posts = allPosts.filter((p) => p.userId === user.id);
console.log("posts", posts.length);
} catch (error) {
console.error("Error fetching posts:", error);
}Adding Timeout Protection
To prevent indefinite waits, we add timeout functionality using AbortController:
const TIMEOUT_DELAY = 5000; // 5 seconds
async function fetchWithRetryAndTimeout<T>(
url: string,
numRetries: number = MAX_RETRIES,
timeout: number = TIMEOUT_DELAY,
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
throw new Error(`Timeout exceeded after ${timeout / 1000} seconds`);
}
if (numRetries > 0) {
console.warn(
`Error fetching ${url}: ${error.message}. Retrying in ${
RETRY_DELAY / 1000
} seconds...`,
);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
clearTimeout(timeoutId);
return await fetchWithRetryAndTimeout(url, numRetries - 1, timeout);
} else {
throw error;
}
} finally {
clearTimeout(timeoutId);
}
}
// Let's find the user with ID 1
try {
const userData = await fetchWithRetryAndTimeout<User[]>(
"https://jsonplaceholder.typicode.com/users",
);
user = userData.find((u) => u.id === 1)!;
console.log("user", user.id, user.name);
} catch (error) {
console.error("Error fetching user:", error);
}
// Let's get posts which belong to user
try {
const allPosts = await fetchWithRetryAndTimeout<Post[]>(
"https://jsonplaceholder.typicode.com/posts",
);
posts = allPosts.filter((p) => p.userId === user.id);
console.log("posts", posts.length);
} catch (error) {
console.error("Error fetching posts:", error);
}const TIMEOUT_DELAY = 5000; // 5 seconds
async function fetchWithRetryAndTimeout<T>(
url: string,
numRetries: number = MAX_RETRIES,
timeout: number = TIMEOUT_DELAY,
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === "AbortError") {
throw new Error(`Timeout exceeded after ${timeout / 1000} seconds`);
}
if (numRetries > 0) {
console.warn(
`Error fetching ${url}: ${error.message}. Retrying in ${
RETRY_DELAY / 1000
} seconds...`,
);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
clearTimeout(timeoutId);
return await fetchWithRetryAndTimeout(url, numRetries - 1, timeout);
} else {
throw error;
}
} finally {
clearTimeout(timeoutId);
}
}
// Let's find the user with ID 1
try {
const userData = await fetchWithRetryAndTimeout<User[]>(
"https://jsonplaceholder.typicode.com/users",
);
user = userData.find((u) => u.id === 1)!;
console.log("user", user.id, user.name);
} catch (error) {
console.error("Error fetching user:", error);
}
// Let's get posts which belong to user
try {
const allPosts = await fetchWithRetryAndTimeout<Post[]>(
"https://jsonplaceholder.typicode.com/posts",
);
posts = allPosts.filter((p) => p.userId === user.id);
console.log("posts", posts.length);
} catch (error) {
console.error("Error fetching posts:", error);
}This works, but the code is becoming complex and harder to maintain.
The Effect Library Solution
Effect provides a more declarative and composable approach. It combines retry logic, timeouts, and schema validation while maintaining type safety at runtime:
import {
Console,
Data,
Duration,
Effect,
pipe,
Schedule,
Schema as S,
} from "effect";
class User extends S.Class<User>("User")({
id: S.Number,
name: S.String,
}) {}
class Post extends S.Class<Post>("Post")({
userId: S.Number,
}) {}
class FetchError extends Data.TaggedError("FetchError")<{
message: string;
}> {}
const fetchErr = (e: unknown) => new FetchError({ message: String(e) });
const retryPolicy = {
times: 3,
schedule: Schedule.exponential(Duration.seconds(2)),
};
const getUser = pipe(
Effect.tryPromise({
try: () =>
fetch("https://jsonplaceholder.typicode.com/users").then((r) => r.json()),
catch: fetchErr,
}),
// Add timeout
Effect.timeout(Duration.seconds(5)),
// Add retry
Effect.retry(retryPolicy),
// Validate the response is Array<User>
Effect.flatMap(S.decodeUnknown(S.Array(User))),
// We're sure about the type now.
Effect.map((us) => us.find((u) => u.id === 1)),
// Make sure we found the user
Effect.flatMap(Effect.fromNullable),
// Handle the error
Effect.catchTags({
// Our error declared above
FetchError: (e) => Console.log("Error fetching user", e.message),
// An error thrown by Effect.fromNullable
NoSuchElementException: () => Console.log("User not found"),
// An error thrown by Schema.decodeUnknown
ParseError: (e) => Console.log("Error parsing user", e.message),
}),
// We have to provide the expected shape of data at the end.
Effect.map((a) => a ?? { id: 0, name: "Unknown" }),
);
const getPosts = (u: User) =>
pipe(
Effect.tryPromise({
try: () =>
fetch("https://jsonplaceholder.typicode.com/posts").then((r) =>
r.json()
),
catch: fetchErr,
}),
// Add timeout
Effect.timeout(Duration.seconds(5)),
// Add retry
Effect.retry(retryPolicy),
// Validate the response is Array<Post>
Effect.flatMap(
S.decodeUnknown(S.Array(Post), {
onExcessProperty: "preserve",
propertyOrder: "none",
}),
),
// We're sure about the type now.
Effect.map((ps) => ps.filter((p) => p.userId === u.id)),
// Handle the error
Effect.catchTags({
// Our error declared above
FetchError: (e) => Console.log("Error fetching posts", e.message),
// An error thrown by Schema.decodeUnknown
ParseError: (e) => Console.log("Error parsing posts", e.message),
}),
// We have to provide the expected shape of data at the end.
Effect.map((a) => a ?? []),
);
await pipe(
getUser,
Effect.tap((u) => Console.log("user", u.id, u.name)),
Effect.flatMap(getPosts),
Effect.tap((p) => Console.log("posts", p.length)),
Effect.runPromise,
);import {
Console,
Data,
Duration,
Effect,
pipe,
Schedule,
Schema as S,
} from "effect";
class User extends S.Class<User>("User")({
id: S.Number,
name: S.String,
}) {}
class Post extends S.Class<Post>("Post")({
userId: S.Number,
}) {}
class FetchError extends Data.TaggedError("FetchError")<{
message: string;
}> {}
const fetchErr = (e: unknown) => new FetchError({ message: String(e) });
const retryPolicy = {
times: 3,
schedule: Schedule.exponential(Duration.seconds(2)),
};
const getUser = pipe(
Effect.tryPromise({
try: () =>
fetch("https://jsonplaceholder.typicode.com/users").then((r) => r.json()),
catch: fetchErr,
}),
// Add timeout
Effect.timeout(Duration.seconds(5)),
// Add retry
Effect.retry(retryPolicy),
// Validate the response is Array<User>
Effect.flatMap(S.decodeUnknown(S.Array(User))),
// We're sure about the type now.
Effect.map((us) => us.find((u) => u.id === 1)),
// Make sure we found the user
Effect.flatMap(Effect.fromNullable),
// Handle the error
Effect.catchTags({
// Our error declared above
FetchError: (e) => Console.log("Error fetching user", e.message),
// An error thrown by Effect.fromNullable
NoSuchElementException: () => Console.log("User not found"),
// An error thrown by Schema.decodeUnknown
ParseError: (e) => Console.log("Error parsing user", e.message),
}),
// We have to provide the expected shape of data at the end.
Effect.map((a) => a ?? { id: 0, name: "Unknown" }),
);
const getPosts = (u: User) =>
pipe(
Effect.tryPromise({
try: () =>
fetch("https://jsonplaceholder.typicode.com/posts").then((r) =>
r.json()
),
catch: fetchErr,
}),
// Add timeout
Effect.timeout(Duration.seconds(5)),
// Add retry
Effect.retry(retryPolicy),
// Validate the response is Array<Post>
Effect.flatMap(
S.decodeUnknown(S.Array(Post), {
onExcessProperty: "preserve",
propertyOrder: "none",
}),
),
// We're sure about the type now.
Effect.map((ps) => ps.filter((p) => p.userId === u.id)),
// Handle the error
Effect.catchTags({
// Our error declared above
FetchError: (e) => Console.log("Error fetching posts", e.message),
// An error thrown by Schema.decodeUnknown
ParseError: (e) => Console.log("Error parsing posts", e.message),
}),
// We have to provide the expected shape of data at the end.
Effect.map((a) => a ?? []),
);
await pipe(
getUser,
Effect.tap((u) => Console.log("user", u.id, u.name)),
Effect.flatMap(getPosts),
Effect.tap((p) => Console.log("posts", p.length)),
Effect.runPromise,
);Key Benefits of Effect
The Effect-based solution provides:
- Type-safe error handling: Each error type is explicitly declared and handled
- Runtime validation: Schema validation ensures data matches expected types
- Declarative composition: Retry, timeout, and error handling are cleanly composed
- Exponential backoff: Built-in scheduling strategies for retries
- Maintainable code: Complex logic expressed in a readable, functional style
Conclusion
Building production-grade software requires thoughtful engineering beyond basic functionality. By progressively enhancing our TypeScript code—from simple Promises to Effect-based solutions—we create more robust, maintainable applications.
Functional programming techniques like those provided by Effect are essential for building software that gracefully handles the complexities of real-world environments.
