Problem: Many times in our Frontend we just "accept" that an API response is what it should be. In Typescript we hide behind generics to type cast, but what if our API is a success with a data structure that we didn't expect? This happened a few times in a recent project. The backend logic for the API hit about 4 different services (that we had no control over), each of these are points of failure. Sometimes one would silently fail causing the API to be a 200
with invalid data. I had a great time.
Here is what I am talking about:
async function getMe() {
try {
const response = await fetch('http://get.profile')
const json: Profile = await response.json()
// Surely `json` will be the shape me need, nothing can go wrong
renderMe(json)
} catch (error) {
// Nothing will ever go wrong
console.error(error)
}
}
Now, 99% of the time, this is fine, and 99% of the time, I do this as well... Probably shouldn't, but here we are. We kinda assume that if something goes wrong with the response then the catch
will catch it. Otherwise, we're all good. This doesn't just happen with custom fetch
calls. In React, if you use a fetch hook, many times it will allow you to pass in generics (useFetch<Profile>()
) to say what the shape of the data will be. Again, this works, I do it, but there isn't a lot of safety from incorrect data.
Idea: I have been thinking about is using a validation library, in this case yup to add a extra layer of protection (this idea will work with any validation library). Usually, if we're working with forms we already have a validation library installed, so we aren't really introducing extra dependencies into our project. Additionally, if you're a Typescript user, these libraries can make type definitions a lot easier as well!
Looking at our example above, we need to introduce 2 extra things. One is our schema and the other is validating our json
.
Schema
Continuing with the get profile idea, we'll create a profile
schema. Depending on how you like to structure your projects. This could be in a profile.schema.ts
or profile.model.ts
file. Allowing you to separate things a little easier.
import { object, string, date } from 'yup'
export const profile = object({
email: string().email().required(),
name: string().required(),
birthday: date().required()
})
/**
* For Typescript users, you can import `InferType` from yup
* and export the Profile type
* export type Profile = InferType<typeof profile>
*/
Validate the data
Now that we have our profile
definition, we can validate our json
, and handle any ValidationError
that yup might throw.
import { ValidationError } from 'yup'
async function getMe() {
try {
const response = await fetch('http://get.profile')
const json = await response.json()
const data = await profile.validate(json, {
stripUnknown: true
})
renderMe(data)
} catch (error) {
if (error instanceof ValidationError) {
alert("The response data is invalid")
return
}
alert("Uncaught error occured")
}
}
You will notice a few things are different here.
- We have removed our generics. If the
validate
call is successful, then we can be confident thatdata
is in ourProfile
shape. - In the
catch
block, we can now test for thisValidationError
and provide the user some extra details about the issue, instead of a generic 'Something went wrong' message. - (Optional) I also passed in
stripUnknown: true
to thevalidate
options. As the name suggests, it will remove any data that isn't in ourprofile
schema. This makes the data more consistent but also 'forces' someone to update the schema if additional data is added.
Using a hook library
In the case that you are using a fetch hook of some description. Some of them may have a validation
option where you can do the same thing. Alternatively, I've seen that many allow for a transform
step. Giving you a chance to change the data before returning it to the user.
const { data, loading, error } = useFetch('http://get.profile', {
transform: async (json) => {
const data = await profile.validate(json)
return data
}
})
That's all folks
Aaaand... that is it. Nothing else to really add. If you take anything away from this it would be, don't fully trust that your data is as expected. Adding additional checks in your components or logic won't hurt anyone. Validation libraries are usually very performant and already installed in many projects, utilising them to standardise schema definitions, type definitions and API data may provide some additional benefits to your projects. It could also help with mocking data, I am sure there are libraries out there that can take one of these schemas and output some JSON that matches the structure.
Below is a Codesandbox (hopefully it shows up) with this idea implemented, feel free to play around a bit. I did set the console to be open, but it sometimes vanishes so it might be best to open it in a different tab. Play around with the me
function and return some weird data to see if the validation works.
Peace! ✌️
Bit of a disclaimer: the ideas in this article are just ideas. I haven't been able to test them out fully yet. Complex data structures or conditional responses might require a more complex schema. I have noticed with complex schemas that the
InferType
becomes a "everything is optional" type, which isn't always ideal.
Top comments (3)
This is an awesome Idea. I hadn't thought about validating API responses like this but it makes sense! Now I will for any API I don't control.
Also, I tried to work with yup for a while but it always seemed pretty finicky. I decided to give Zod a shot and within about an hour I was totally sold. Same idea as Yup, but Zod's usage just seems to fit better.
Yep Zod is nice, I've used it once or twice in the past. Everytime I jump into a project it seems to have yup installed so that is where most of my experience is 😂
Hello,
Thank you for this nicelly written article.
In case someone wants fetching and both typescript and runtime checks, you can take a look at zodios. It has schemas validation with zod and fetch with axios.