Skip to content

Instantly share code, notes, and snippets.

@ivan-demchenko
Last active October 15, 2024 06:36
Show Gist options
  • Save ivan-demchenko/6951c2a64b7f43565dbeaae30f24320f to your computer and use it in GitHub Desktop.
Save ivan-demchenko/6951c2a64b7f43565dbeaae30f24320f to your computer and use it in GitHub Desktop.
TypeScript + React safe API handling

Safe API handling with TypeScript and React

I wrote some similar code during my tenure at Postman (but for a different environment).

Main points are:

  • totality of functions
  • dependency injections
  • turn optional data into well-defined invariants
  • error handling
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { Result } from "ts-results-es";
import { HttpError } from "./api.service";
import { BlogDataService, debugApiError, Posts } from "./api.service";
export type HttpRequestStatus<T> =
| { tag: "Idle" }
| { tag: "Loading" }
| { tag: "Error"; error: HttpError }
| { tag: "Ready"; data: T };
/**
* Map TanStack's query state into finite set of well-defined variants.
* This way, TypeScript can help with types and we'll
* require less guess work during rendering
*/
const queryResultToApiState = <T>(
queryRes: UseQueryResult<Result<T, HttpError>>,
): HttpRequestStatus<T> => {
if (queryRes.isLoading) {
return { tag: "Loading" };
}
if (queryRes.data?.isOk()) {
return { tag: "Ready", data: queryRes.data.value };
}
if (queryRes.data?.isErr()) {
return { tag: "Error", error: queryRes.data.error };
}
return { tag: "Idle" };
};
export function useListPosts(
service: BlogDataService,
): HttpRequestStatus<Posts> {
const query = useQuery({
queryKey: ["posts"],
queryFn: async () => {
const res = await service.listPosts();
// Log an error for the debugging purposes
if (res.isErr()) {
console.log(debugApiError(res.error));
}
return res;
},
});
return queryResultToApiState(query);
}
import { Result, Ok, Err } from "ts-results-es";
import { z } from "zod";
import { assertExhaustive } from "./utils";
/**
* Handling JSON
*/
/**
* Parse Response's JSON is a safe way
* @param {Response} response
* @returns {Result}
*/
const safeResponseJson = async <T>(
response: Response,
): Promise<Result<T, string>> => {
try {
const raw = await response.json();
return new Ok(raw);
} catch {
return new Err("Expected JSON, but received something else");
}
};
/**
* Verify the correctness of data
*/
/**
* Try parsing data using schema and return a Result
* @param {ZodSchema} schema schema to validate the raw data against
* @param {unknown} raw
* @returns {Result}
*/
const decodeJson = <T>(
schema: z.ZodSchema<T>,
raw: unknown,
): Result<T, string[]> => {
const result = schema.safeParse(raw);
return result.success
? new Ok(result.data)
: new Err(result.error.format()._errors);
};
/**
* API
*/
export type HttpError =
| { tag: "network_error"; details: string }
| { tag: "bad_status"; status: number }
| { tag: "bad_body"; reason: string };
/**
* Turns a value that represents an API error into a trace string
* @param {HttpError} err - An error to turn into a string
* @returns {string}
*/
export const apiErrorToStr = (err: HttpError): string => {
switch (err.tag) {
case "network_error":
return "Network error happened. Check your internet connection.";
case "bad_body":
case "bad_status":
return "Something went wrong on our end. Please, try again later.";
default:
assertExhaustive(err)
}
};
/**
* Turns a value that represents an API error into a trace string
* @param {HttpError} err - An error to turn into a string
* @returns {string}
*/
export const debugApiError = (err: HttpError): string => {
switch (err.tag) {
case "network_error":
return `network error: ${err.details}`;
case "bad_body":
return `bad body: ${err.reason}`;
case "bad_status":
return `bad status: ${err.status}`;
default:
assertExhaustive(err);
}
};
/**
* Data Schemas
*/
export const postSchema = z.object({
userId: z.number(),
id: z.number(),
title: z.string(),
body: z.string(),
});
export type Post = z.infer<typeof postSchema>;
export const postsSchema = z.array(postSchema);
export type Posts = z.infer<typeof postsSchema>;
/**
* Service Interface + Default implementation
*/
export interface BlogDataService {
/**
* Fetch list of posts and return either Posts or Error
*/
listPosts(): Promise<Result<Posts, HttpError>>;
}
export class HttpBlogDataService implements BlogDataService {
async listPosts(): Promise<Result<Posts, HttpError>> {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts",
);
if (!response.ok) { // status is outside of 200-299 range
return new Err({ tag: "bad_status", status: response.status });
}
return (await safeResponseJson(response))
.mapErr<HttpError>((jsonParsingError) => ({
tag: "bad_body",
reason: jsonParsingError,
}))
.andThen((rawJson) => {
return decodeJson(postsSchema, rawJson).mapErr<HttpError>(
(decodingErrors) => ({
tag: "bad_body",
reason: decodingErrors.join("\n"),
}),
);
});
} catch (e) {
if (e instanceof Error) {
return new Err<HttpError>({ tag: "network_error", details: e.message });
}
return new Err<HttpError>({
tag: "network_error",
details: "Unknown error",
});
}
}
}
import { FC } from "react";
import { HttpError, apiErrorToStr, BlogDataService, Post } from "./api.service";
import { useListPosts } from "./api.hooks";
import { assertExhaustive } from "./utils";
const ShowLoading: FC = () => (
<div className="p-4 border border-blue-700 bg-blue-100">Loading...</div>
);
const ShowError: FC<{ error: HttpError }> = ({ error }) => (
<div className="p-4 border border-red-700 bg-red-100">
{apiErrorToStr(error)}
</div>
);
const PostPreview: FC<{ post: Post }> = (props) => {
const { post } = props;
return (
<div>
<h4 className="text-xl">{post.title}</h4>
<p className="mb-4">{post.body}</p>
</div>
);
};
const App: FC<{ blogApiService: BlogDataService }> = (props) => {
const apiRequestStatus = useListPosts(props.blogApiService);
switch (apiRequestStatus.tag) {
case "Idle":
case "Loading":
return <ShowLoading />;
case "Error":
return <ShowError error={apiRequestStatus.error} />;
case "Ready":
return (
<>
<h1 className="text-3xl mb-3">Posts:</h1>
{apiRequestStatus.data.map((post) => (
<PostPreview key={post.id} post={post} />
))}
</>
);
default:
return assertExhaustive(apiRequestStatus);
}
};
export default App;
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HttpBlogDataService, type BlogDataService } from "./api.service.ts";
import App from "./App.tsx";
import "./index.css";
function main(
blogApiService: BlogDataService
) {
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App blogApiService={blogApiService} />
</QueryClientProvider>
</StrictMode>,
);
}
main(new HttpBlogDataService())
/**
* Force TypeScript to handle all values of discriminant in unions
*
* @example
* ```ts
* type Option = 'a' | 'b';
* declare const option: Option;
* switch (option) {
* case 'a': return 1;
* case 'b': return 2;
* default: assertExhaustive(option)
* }
*/
export function assertExhaustive(_case: never): never {
console.error(_case);
throw new Error('Reached unexpected case in exhaustive switch');
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment