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 returnsany
, 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!