Enhancing TypeScript Effectiveness

6 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 run directly. Download the zip file here.

Introduction

TypeScript and JavaScript offer powerful tools for building applications, but when aiming for production-grade software, there are additional considerations to keep in mind. Let's explore some approaches to enhance our code's robustness and reliability.

We'll examine a common scenario: fetching a user and their associated posts. Through this example, we'll see how our coding choices can influence the final shape and quality of our software.

For our demonstration, we'll use the JSONPlaceholder API.

Starting with Promises

Let's begin with a straightforward approach using Promises:

// file: promises.ts
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);

When executed, this code should produce output similar to:

user 1 Leanne Graham
posts 10

Great! We've successfully retrieved our data. However, there are some aspects we might want to improve.

Addressing Potential Issues

Type Safety

  • The r.json() call returns any, which doesn't leverage TypeScript's type system effectively.
  • Our result types are Promise<any>, which doesn't indicate the possibility of fetch failures.

Error Handling

To make our code more robust, we should implement proper error handling:

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

Implementing Retries

To handle temporary network issues, we can add retry logic:

  • Define constants for the maximum number of retries and the delay between retries
  • Create a fetchWithRetry function that takes a URL and an optional number of retries
  • Inside the fetchWithRetry function:
  • Attempt to fetch the data from the provided URL
    • If the fetch fails, check if any retries are remaining
    • If retries are remaining, log a warning, delay for the specified time, and retry the fetch
    • If no retries are remaining, throw the error

Use the fetchWithRetry function to fetch the user data and posts data

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 Timeouts

To prevent indefinite waiting, we can implement timeouts:

  • Define a constant for the timeout delay
  • Create a fetchWithRetryAndTimeout function that takes a URL, an optional number of retries, and an optional timeout
  • Inside the fetchWithRetryAndTimeout function:
    • Create a new AbortController instance
    • Set a timeout using setTimeout to abort the fetch if the timeout is reached
    • Pass the AbortController.signal as an option to the fetch call
    • If an AbortError is thrown due to the timeout, catch it and throw a new error indicating the timeout was exceeded
    • If a different error occurs, follow the same retry logic as in fetchWithRetry
    • In the finally block, clear the timeout

Use the fetchWithRetryAndTimeout function to fetch the user data and posts data

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

A More Efficient Approach

While the above improvements enhance our code's robustness, they also increase its complexity. Let's explore a more elegant solution using the Effect library:

We're going to begin a journey which is going to last multiple posts and notebooks, because there's a lot to talk about. Type Driven Development is going to yield Efficient and Effective ways to handle everyday problems with clear easy, to follow notebooks published bi-weekly.

For the very first edition we're going to take the code above and refactor it using Type Driven Development methodology to enable maximum efficiency, understanding and real actual type-safety.

The signature

Effects are descriptions of a computation and not the final result.

A - the result type

Describes the expected output of the computation of this effect

E - the error type

Describes the expected errors that may arise during the computation of this effect

R - the environment type

Describes the environment that is made up of dependencies consumed by this effect

interface Effect<A, E, R> {}
// file: effect.ts
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,
);

This approach using Effect provides a more declarative and composable way to handle our data fetching, error management, retries, and timeouts. Type-safety of the objects we work with is guaranteed at runtime not just at dev-time. The S.decodeUnknown function ensures that the data we receive is of the expected Schema.

Conclusion

We've explored various methods to enhance our TypeScript code's effectiveness and reliability. While the journey from simple Promises to more robust solutions may seem complex, it's an essential part of building production-grade software. The Effect library offers a powerful toolset for managing these complexities in a more elegant and maintainable way. As we continue to develop our skills in TypeScript and functional programming, we'll find even more ways to write effective, robust code. In future discussions, we'll delve deeper into Type Driven Development and explore additional techniques to elevate our code quality. Happy coding!