Recently I came across an interesting bug, it sounded something like this: the user does something, but nothing happens. After a bit of digging, I realized that the problem was that the backend returned an error, and from the client side it was not processed in any way. After some thought, I decided to make a default error handler for the entire application. And today I will tell you what formats for common handlers exist and why it is better to agree on a single format with the backend than to fence a new solution every time.
The error must be accessible from the outside (the handler must not eat it). It’s important
because every time you hide errors behind abstractions, you get big difficulties afterward.
The handler must be able to work with Zero-configuration (configured defaults). It’s important because it’s easier to read and work with
Agree with the backend about the general error format, you can choose one of the universal ones described below
// Backend error format
type ApiError = {
message: string
}
Make a general request handler that can be configured, like this:
type Config = { errorHandler: (errors: ApiError[]) => void }
// hook for common usage
const useQuery = ({ errorHandler = defaultErrorHandler, url }: { url: string } & Partial<Config>) => {
const [errors, setErrors] = useState();
const [data, setData] = useState();
const [loading, setLoading] = useState();
useEffect(() => {
setLoading(true);
fetch(url)
.then(data => data.json())
.then(({ statusCode, status, ...data }) => {
if (statusCode === 404) {
const errors = [data.message];
errorHandler(errors);
setErrors(errors);
} else {
setData(data);
}
})
.catch(e => {
setErrors([e])
})
.finally(() => {
setLoading(false)
})
}, [url])
return { errors, data, loading }
}
Use a common request handler everywhere and feel happy and joyful that users will never again be puzzled by what happens with errors
const { breed } = useParams();
const { data, errors } = useQuery({
url: `https://my-random-url`,
errorHandler: console.error
});
I chose the inner working format with Either because sometimes I want to extract errors by different functions and easily decompose my solution into small functions (RequestWrapper is a simple example of Either monad). Also, it’s better typed than the catch method, so you can try to do it this way :)
type RequestWrapper<T, E extends ApiError> = [T, undefined] | [undefined, E[]];
const convertToEither = async <T, E extends ApiError>(req: Promise<T>): Promise<RequestWrapper<T, E>> => {
try {
return [await req, undefined];
} catch (e) {
return [undefined, [e]]
}
}
I am too lazy to realize logger and toast right now, so I just mock it :)
class Toast {
static showError = console.error;
}
class Loggger {
static logError = console.log
}
I don’t want to handle and extract errors in one place, so I decided to make an extract function
const extractErrors = async <T extends ApiResult, E extends ApiError>(wrapper: RequestWrapper<T, E>): Promise<RequestWrapper<T, ApiError>> => {
const [res, errors = []] = wrapper;
if (res?.status === 'incorrect-field-values') {
return [, [...errors, ...res.errors]]
}
return wrapper;
}
I want to pass my error handler from outside, so I configure it in a fabric way
const defaultErrorHandler = <E extends ApiError>(errors: E[]) => {
errors.forEach(error => {
Toast.showError(error);
})
}
const defaultErrorHandlerFabric = (errorHandler = defaultErrorHandler) => async <T, E extends ApiError>(wrapper: RequestWrapper<T, E>) => {
const [, errors] = wrapper;
if (errors?.length) {
errorHandler(errors)
}
return wrapper;
}
Just put it all together
const handleRequest = <T extends Promise<Response>>(req: T, config: Required<Config>) => {
return convertToEither(req)
.then(convertToJson)
.then(extractErrors)
.then(defaultErrorHandlerFabric(config.errorHandler))
.then(defaultLogger)
}
const useQuery = ({ errorHandler = defaultErrorHandler, url }: { url: string } & Partial<Config>) => {
const [errors, setErrors] = useState();
const [data, setData] = useState();
const [loading, setLoading] = useState();
useEffect(() => {
setLoading(true);
handleRequest(fetch(url), { errorHandler })
.then(([data, errors]) => {
setErrors(errors);
setData(data);
})
.finally(() => {
setLoading(false)
})
}, [url])
return { errors, data, loading }
}