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