No more “Trust me, bro” data fetching in TypeScript applications
TypeScript can help prevent a whole category of software defects, ensuring data flows smoothly and predictably throughout an application — I have no idea how I built applications without it in the past. When used insufficiently, however, TypeScript does not offer this protection — rather, application teams need to invest in adequate type safety, particularly when fetching data — all while avoiding the temptation to tell the TypeScript compiler, "trust me, bro."
This is the first article in a series about predictable, type-safe data fetching in TypeScript applications. In part one, I will be covering common type checking failures when fetching data. In part two, I'll explore automated integration testing strategies that work well with type-safe data fetching.
“Trust me, bro” data fetching
When fetching data, the JSON response is never typed by default, as the inferred
type of a typical fetching function is Promise<any>
:
async function listAccounts() {
const res = await fetch('https://api.nicklemmon.com/accounts/list')
if (!res.ok) throw Error('Account fetching failed!')
return await res.json() // 👈 The inferred return type is `Promise<any>`
}
In typical applications, this problem is addressed via a type assertion using
as
. When using as
in this scenario, this is a "trust me, bro" moment. The
developer is telling the TypeScript compiler to not worry about the shape of the
response — "Trust me, bro," the shape is always exactly what I say it
is. No need to check yourself, TypeScript!
type Account = {
id: string
name: string
holdings: number
}
async function listAccounts() {
const res = await fetch('https://api.nicklemmon.com/accounts/list')
if (!res.ok) throw Error('Account fetching failed!')
// Here is the type assertion 👇
// "Trust me, bro! The response is definitely an array of accounts."
return (await res.json()) as Array<Account>
}
At this point, the data you receive could come in any shape, and by using
as
, we have simply turned off any adequate protection that TypeScript might
provide when it comes to the shape of the data flowing throughout our
application.
Even fully fledged, popular frontend frameworks (like Angular), who expose a service for data fetching (see the HttpClient documentation for more), still handle data typing via the "trust me, bro" strategy out of the box. In this case, a generic is used instead of a type assertion, but the end result is the same.
// This isn't using `as`, but a generic - the end result is the same - the shape of the data is not validated at runtime but is assumed!
// "Trust me, bro! The account list is *always* in the right shape!"
http.get<Array<Account>>('https://api.nicklemmon.com/accounts/list')
Unfortunately, this leaves a lot to be desired. If the shape of the API response
ever changes unexpectedly or if the type definition of Account
is
inaccurate, any application logic that assumes the return type of
listAccounts()
is Array<Account>
can never be completely trusted. Even
though TypeScript is used in the data fetching function, runtime errors can and
will occur unpredictably over time.
Actually type safe (and predictable) data fetching
Instead of blindly assuming that your data is shaped in a certain way, it is best to first validate that data at runtime, typically using a schema validation library like zod or valibot.
Let's re-write our listAccounts
function to validate the shape of the response
using zod
:
import { z } from 'zod'
// This feels similar to TypeScript, but available at runtime
const AccountSchema = z.object({
id: z.string(),
name: z.string(),
holdings: z.number(),
})
async function listAccounts() {
const res = await fetch('https://api.nicklemmon.com/accounts/list')
try {
if (!res.ok) throw Error('Account fetching failed!')
// We `.parse` the awaited JSON response to ensure its in the right shape,
// and catch any errors that might result. With a monitoring service in place,
// we could flag type runtime errors more cleanly for fixing by API teams.
const json = AccountSchema.parse(await res.json())
return json // The inferred return type will match the shape of the `AccountSchema`
} catch (err) {
throw Error(String(err))
}
}
The AccountSchema
looks awfully similar to TypeScript, but with a few extra
lines we can have both a schema definition which can handle validation at
runtime and a type definition at build time which are inherently linked:
import { z } from 'zod'
// Runtime schema
const AccountSchema = z.object({
id: z.string(),
name: z.string(),
holdings: z.number(),
})
// Build time type definition
type Account = z.infer<typeof AccountSchema>
Now we can have type safe data fetching with type definitions and runtime type validation — all of which are tightly coupled to produce predictable results.
Schema and type usage in the UI layer
Now that we have a defined type, our UI can fetch data, and then render the result predictably, all while passing the data down the component tree safely. The following example uses TanStack Router and React to help orchestrate fetching and rendering.
First, we define our route. We can import our listAccounts
function and use it
in the route definition:
import { createFileRoute } from '@tanstack/react-router'
import { listAccounts } from '../api'
export const Route = createFileRoute('/accounts/$list')({
component: RouteComponent,
loader: async () => {
// Remember, this function validates the shape of the data
// or throws an error if the data is in an unexpected shape
return await listAccounts()
},
})
function RouteComponent() {
// The account list is made available through the baked-in `useLoaderData` hook
const accountList = Route.useLoaderData()
return <h1>List of accounts</h1>
}
Additionally, we can define an <AccountListTable />
component, which expects
an accountList
as a prop:
import type { Account } from '../api'
function AccountListTable({ accountList }: { accountList: Array<Account> }) {
return (
<table>{/* Logic for rendering the account list table goes here */}</table>
)
}
We can then render the <AccountListTable />
in our accounts list route,
achieving end-to-end type safety, without relying on "trust me, bro" TypeScript
techniques. The shape of our API response may change unexpectedly, but error
handling will more catch errors at their source, making them dramatically easier
to debug.
import { createFileRoute } from '@tanstack/react-router'
import { listAccounts } from '../api.ts'
import { AccountListTable } from '../account-list-table.tsx'
export const Route = createFileRoute('/accounts/$list')({
component: RouteComponent,
loader: async () => {
// Remember, this function validates the shape of the data
// or throws an error if the data is in an unexpected shape
return await listAccounts()
},
})
function RouteComponent() {
// The account list is made available through the baked-in `useLoaderData` hook
const accountList = Route.useLoaderData()
return (
<div>
<h1>List of accounts</h1>
{/* Ta-dah! The inferred type from the `listAccounts` function matches the expected type defined explicitly in the <AccountListTable /> */}
<AccountListTable accountList={accountList} />
</div>
)
}
Conclusion
zod
(and other similar tools, like valibot) provide a
protective layer against unexpected data shapes in TypeScript applications,
improving the predictability and reliability of application code, all while
avoiding the "trust me, bro" mistakes that are common when handling data
fetching.
This is the first article in a series about predictable, type-safe data fetching in TypeScript applications. In part two, I'll explore automated integration testing strategies that work well with type-safe data fetching.
About Me
I’m a frontend engineering manager working for Truist Financial currently based in in Columbia, Maryland.
I’m driven to spearhead, design, and build accessible design systems and web applications with a great underlying developer experience in mind.