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