Using Typescript type guards to validate API responses
![Jon Smoley](/jon-hero.jpg)
Jon Smoley
·
Senior Software Engineer
Published on Monday Oct 9, 2023
![hero image](/blog/typeguardingApiResponses/hero.png)
Whether it's a database query, API call, or reading from a local file, every Node.js developer has had to pull data into their code. Good developers might take the extra steps to validate and verify what they're reading, but if you're using Typescript it can be tricky to accurately assign types to incoming data that could essentially be anything!
Let's say we're making an API call, and reading data returned from a the Dog API.
1// This is a real api!
2const DOG_API = 'https://dog.ceo/api'
3
4const getRandomDog = async () => {
5 const url = `${DOG_API}/breeds/image/random`
6 const dogApiResponse = await fetch(url)
7 const json = await dogApiResponse.json()
8
9 return json
10}
If everything is plugged in and we successfully parse the returned data, we'd expect the returned data to be a RandomDog
in a shape like this:
1interface RandomDog {
2 message: string
3 status: string
4}
Unsurprisingly, the fetch
function has no clue what the shape of the jsonified returned data is. (when we call .json()
on the response, it's type is any
) But this isn't a bug; fetch
is supposed to be used for many API calls, and it's up to the developer to determine the type. Also, imagine if the owner of the Dog API decides to later tweak a property or change the shape of the data, how would Typescript ever know? We need to create our own strategy to figure out how to add types after the call is made.
Typescript supports casting types (called type assertions) that allow us to force type behavior if typescript support is lacking. With the right syntax (using as
) you can force almost any type, for better or for worse.
1const validateDog = async () => {
2 const response = await getRandomDog()
3
4 const randomDog = response as RandomDog
5
6 // randomDog.message (string) ✅
7 // randomDog.status (string) ✅
8
9 return randomDog
10}
Since we know that our route should return a RandomDog
, we could cast the response from the API call to be of that type, and we get correct typing and syntax highlighting in our IDE. Nice!
However, as
really trusts you to make the right decision, and if you aren't careful, you can end up doing more harm than good. All the code below is valid Typescript.
1const getUserRecord = () => {
2 const record = db.table.select(1)
3
4 // return a UserRecord even if we don't get one
5 return (record as UserRecord)
6}
7
8const getNumber = () => {
9 const str = 'Im totally a string'
10
11 // return a variable with inaccurate type
12 return str as unknown as number
13}
So, our function uses a type assertion to give us correct types, but imagine sometime later, several months after our code has been deployed to production, the author of the Dog API changes the shape of the API response. Since we declared that the above response will always be of type RandomDog
, we never catch the changed data. We have a bug. The returned data is different, and we see the ever famous Cannot read properties of undefined
error. Our boss makes us deploy a patch on a Saturday while we're on vacation in Maui. If only we validated our data!
"But wait!" you say, "What if I just add if
checks to see if the properties exist?"
1const validateDog = async () => {
2 const response = await getRandomDog()
3
4 if (
5 response
6 && typeof response.message === 'string'
7 && typeof response.status === 'string'
8 ) {
9 return (response as RandomDog)
10 } else {
11 // handle error case
12 }
13}
Our vacations are saved! Our function now does validation, we have correct typing, and we have a way to handle unexpected changes in our API contracts. We've solved our issues, but our code is looking a little unweildy. Wouldn't it be great if Typescript had syntax for this type of logic?
Type Guard Functions
A type guard function is a function that can assign a type to a variable, but only if conditions are met, similar to what we just saw above. They always return a boolean, and use a new keyword, is
, as part of the return signature.
1const isDog = (maybeDog: any): maybeDog is RandomDog => {
2 return maybeDog && (
3 typeof maybeDog.message === 'string' &&
4 typeof maybeDog.status === 'string'
5 )
6}
Using a type guard is the combination of type inference (like when using as RandomDog
), and runtime checks. We get correct typing in our IDE, and any deployed Javascript has extra checks. Putting everything together we would use the type guard like a simple function that returns a boolean.
1const validateDog = async (): Promise<RandomDog> => {
2 const response = await getRandomDog()
3
4 if (isDog(response)) {
5 // in this `if` block, `response` resolves to
6 // be a `RandomDog`
7 return response
8 } else {
9 // in this `else` block, `response` resolves to
10 // be `any`
11 throw new Error()
12 }
13}
14
15const isDog = (maybeDog: any): maybeDog is RandomDog => {
16 return maybeDog && (
17 typeof maybeDog.message === 'string' &&
18 typeof maybeDog.status === 'string'
19 )
20}
Now our validateDog()
function looks much more maintainable, has runtime checks, correct types, and all of it using our fancy new Typescript syntax.
Helpful type guards
Now that you're ready to mix and match type guard functions on your own, take a look at some commonly-used type guards.
Primitives (Numbers, Strings, etc)
1const isNumber = (n: any): n is number => {
2 return typeof n === 'number'
3}
4
5const isBoolean = (b: any): b is boolean => {
6 return typeof b === 'boolean'
7}
8
9const isString = (s: any): s is string => {
10 return typeof s === 'string'
11}
String unions
String union types can't be used inside of if
/else
statements, so we can't directly check against them. To create a type guard, we can change the way we declare our string union types by instead defining them from a string array, and using that array in the type guard.
1// OLD - string union literal
2type Status = 'success' | 'failed' | 'pending'
3
4// NEW - string union derived from string array
5const STATUSES = ['success', 'failed', 'pending'] as const
6type Status = typeof STATUSES[number]
Then we use the string array in our type guard.
1const STATUSES = ['success', 'failed', 'pending'] as const
2type Status = typeof STATUSES[number]
3
4const isStatus = (maybeStatus: any): maybeStatus is Status => {
5 return STATUSES.includes(maybeStatus)
6}
Classes
For classes we can use the instanceof
operator.
1class HttpRequest {
2 status: string
3}
4
5const isHttpRequest = (h: any): h is HttpRequest => {
6 return (h instanceof HttpRequest)
7}