Enhancing TypeScript Effectiveness

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() returns any, 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.