June 13, 2023
June 13, 2023
God, grant me the serenity to accept the things I cannot change,
the courage to change the things I can,
and the wisdom to know the difference.
You've probably heard at least one of the versions of this Serenity prayer. It's not only a pretty useful piece of advice for life, but also a helpful tip towards handling remote data successfully when building mobile apps.
One of the key strategies in building a reliable app is to understand what data is flowing through your system. Unless you work in a small team that believes in full-stack development, you won't have much control over what comes to your app from the backend services. This data can come with multiple types of issues with a varying impact:
distanceInMiles
that returns kilometers instead), etc. Those issues can be fixed on your side, or accepted as quirks and propagated across your codebase as well.Of course, a lot of those issues are simply differences between how your team sees the domain (and how things should be) and how other teams do. Sometimes there's a business logic error hidden there that you can simply ask the other team to fix, but a lot of times it's a matter of preference. Of course, even if you're right it doesn't mean that the problem is worth fixing. Good luck raising priority on your ticket that somebody misspelled referrer.
If you value maintainability in the long-term over moving fast and breaking things, you'll probably want to draw a line where the data enters your app and put a border guard there that does two things:
You could hand-craft a layer of artisanal code somewhere between low-level networking and your business logic, but you don't have to. Enter zod.
Let's take as an example the Marvel API's endpoint for characters. We'd like to represent a list of them with a possibility of fetching more data for a detailed screen should our user tap any of them. We could do manual checks for the fields that we need (ID
- to later fetch the details, name
- to display it in our list, URL
- to fetch a thumbnail image, and modified
time - to possibly help us with cache invalidation), but we can also let zod
do it for us by defining a schema that we're interested in:
import z from 'zod'const HeroUrlBE = z.object({type: z.string(),url: z.string().url(),})const HeroBESchema = z.object({id: z.number(),modified: z.string().datetime({ offset: true }),name: z.string(),urls: z.array(HeroUrlBE).nonempty(),})
Then we can simply use this schema to validate data we received using HeroBESchema.parse(someHeroWannabe)
.
Note: I often use BE
sufix to mark types or schemas that represent remote (backend) data representation.
As I mentioned above, checking correctness is just the first step, because we'd also like to control the shape of data inside our app.
In that particular case, we get an array of URLs, but we only need one. We can define a schema transformation that will take the remote representation and massage it into shape that we'd like to use. For example:
import { D } from '@mobily/ts-belt'const HeroFESchema = HeroBESchema.transform(hero => ({url: hero.urls[0].url,...D.deleteKeys(hero, ['urls']),}))
That will strip the fields that you're not interested in (here urls
) and set a new one that you want to use (url
).
Note: anything that was present in the original data that you've received, but hasn't been mentioned in the validation schema has been automatically removed to avoid any surprises.
For those of us that use TypeScript a natural question arises - wouldn't I have to do double work now to define my types? If that were necessary, I wouldn't be writing this blog post. You can infer types from the schema and easily share them with other parts of your app:
import z from 'zod'export type HeroBE = z.infer<typeof HeroBESchema>export type Hero = z.infer<typeof HeroFESchema>
(For those of us who don't use TypeScript another natural question arises - why am I not using TypeScript?)
I mentioned earlier in this article that sometimes you'll need to decide what to do about data that you got, but can't do anything with (like the ecommerce product with no name). Of course, depending on your domain, you might have a better approach, but a good starting point would be:
Stop this data from entering your app. Note that we’re talking here about invalid objects (data is not there), not malformed ones (the data is there, just not in a great shape). If you can’t use it, ignore it.
File an error. If you get an object that you can’t use, it means there’s an error somewhere. Either a technical one (in data validation on one of the sides), or a communication one (there’s a misunderstanding about the contract between the systems). Usually we’d use the same Sentry reporting system that we use for handling any other exception.
import z from 'zod'const mapBEToFE = (hero: HeroBE): Hero | undefined => {try {return HeroFESchema.parse(hero)} catch (error) {if (error instanceof z.ZodError) {console.error('[mapBEToFE] Not valid: ', error.issues)// Report error to Sentryreturn undefined}console.error('[mapBEToFE] Parsing error:', error)throw error}}export const fetchHeroes = async () => {// (…)try {const response = await fetch(requestUrl, {headers: {Accept: 'application/json',},})const heroes = (await response.json())?.data?.resultsreturn heroes.map(mapBEToFE).filter(hero => hero !== undefined)} catch (error) {console.error('[fetchHeroes] Networking error:', error)throw error}}
Note: we don't throw away the whole collection when only one of the items is invalid.
If you want to send data back (for example to update a record), you'll need to adhere to the backend service's data format. That usually means translating data back to its original form.
Unfortunately, it requires some monkey work. While we do know what Hero
and HeroBE
are like, zod
does not give us an easy way to reverse the transformation that we've defined before. However, because we can be confident in our data being valid (because we use TypeScript, right?) we don't really need zod
here. This map function can be as simple as:
import { D } from '@mobily/ts-belt'const mapFEToBE = (hero: Hero): HeroBE => ({urls: [{ type: 'thumbnail', url: hero.url }],...D.deleteKeys(hero, ['url']),})
Note: of course here we squashed the URLs array into just one URL and then later made an array back from it, so we've lost some data. We probably should not send this particular object as an update (but Marvel API doesn't allow any POST requests anyway).
zod
offers a nice way to implement the (often tedious) border guards for your remote data with only minimal boilerplate. If you're doing it by hand (or not doing it 🙈), I'd strongly recommend giving this approach a try.
However, if you're already using zod
and looking for a solution with even less boilerplate, we might soon have an option for you as well. Watch this space!
If you liked this post, why don't you subscribe for more content? If you're as old-school as we are, you can just grab the RSS feed of this blog. Or enroll to the course described below!
Alternatively, if audio's more your thing why don't you subscribe to our podcast! We're still figuring out what it's going to be, but already quite a few episodes are waiting for you to check them out.