Fully Typed Web Apps

Kent C. Dodds
Kent C. Dodds
Time to read~14 minutes
Published26 October, 2022

TypeScript is an enormous part of the web industry. And there’s a good reason for this. It’s amazing. And I’m not just talking about this:

function add(a: number, b: number) {
  return a + b

add(1, 2) // type checks just fine
add('one', 3) // does not type check

I mean, that’s cool and all, but what I’m talking about is code that’s more like this:

workshop type diafgram
loading image...

A type that flows throughout the entire program (including between the frontend and backend). In the real world this is how it works and it can be a terrifying prospect to decide one day that you want to turn that “Seats Left” field into a combination of “Total Seats” and “Sold Seats” fields. Without types guiding you through that refactor you’re gonna have a hard time. I sure hope you have some solid unit tests.

But I don’t want to spend this article convincing you how great types in JavaScript are. Instead, I want to share my excitement about how great end-to-end type safety has been and show you how you can accomplish it for yourself in your own applications.

First, what I mean by end-to-end type safety, I’m talking about having type safety from the database, through the backend code, all the way to the UI and back again. Now, I realize that everyone’s in different circumstances. You may not have control over your database. When I was at PayPal, I consumed a dozen services built by different teams. I never touched a database directly. So I understand if getting true end-to-end type safety may require collaboration. But hopefully I can help get you on the right track to get as far as possible in your own situation.

The main thing that makes end-to-end type safety difficult is simple: boundaries.

The secret to fully typed web apps is typing the boundaries.

In the web, we have a lot of boundaries. Some of these you may have in mind, and others you may not consider. Here are a few examples of boundaries you’ll encounter on the web:

// synchronizing "ticket" state with local storage
const ticketData = JSON.parse(localStorage.get('ticket'))
//    ^? any 😱

// getting values from a form
// <form>
//   ...
//   <input type="date" name="workshop-date" />
//   ...
// </form>
const workshopDate = form.elements.namedItem('workshop-date')
//    ^? Element | RadioNodeList | null 😵

// fetching data from an API
const data = await fetch('/api/workshops').then(r => r.json())
//    ^? any 😭

// getting config and/or conventional params (like from Remix or React Router)
const { workshopId } = useParams()
//      ^? string | undefined 🥴

// reading/parsing a string from fs
const workshops = YAML.parse(await fs.readFile('./workshops.yml'))
//    ^? any 🤔

// reading from a database
const data = await SQL`select * from workshops`
//    ^? any 😬

// reading form data from a request
const description = formData.get('description')
//    ^? FormDataEntryValue | null 🧐

There are plenty more examples, but these are some common boundaries you’ll run into. Some boundaries we have here are (there are more):

  1. Local storage
  2. User input
  3. Network
  4. Config-based or Conventions
  5. File system
  6. Database requests

The thing is, it is impossible to be 100% certain that what you’re getting back from a boundary is what you expect. This bears repeating: It is impossible. You can certainly “make TypeScript happy” by throwing around a as Workshop type cast and whatever, but you’re just hiding the problem there. The file could have changed out from under you by another process, the API could have changed, the user could have modified the DOM manually for goodness sake. There’s just no way to know for certain that the result that came to you across that boundary is what you expected it to be.

But, there are some things you can do to side-step this. You can either:

  1. Write type guards/type assertion functions
  2. Use a tool that generates the types (gives you 99% confidence)
  3. Help inform TypeScript of your convention/configuration

So, let’s take a look using these strategies to get end to end type safety by addressing the boundaries of web apps.

Type Guards/Assertion functions

This is definitely the most effective way to make sure what you got across the boundary is what you expected. You literally write code to check it! Here’s a simple example of a type guard:

const { workshopId } = useParams()
if (workshopId) {
  // you've got a workshopId to work with now and TypeScript knows it
} else {
  // do whatever you want without the workshopId

At this point, some of you who are annoyed by having to “appease” the TypeScript compiler. If you’re so positive that workshopId will be what you expect, then just throw an error (it’ll be more helpful than the error you’d get if you ignored this potential problem anyway).

const { workshopId } = useParams()
if (!workshopId) {
  throw new Error('workshopId not available')

There’s a handy utility I use in almost all my projects to make this slightly nicer on the eyes:

import invariant from 'tiny-invariant'

const { workshopId } = useParams()
invariant(workshopId, 'workshopId not available')

From the tiny-invariant README:

An invariant function takes a value, and if the value is falsy then the invariant function will throw. If the value is truthy, then the function will not throw.

Having to add the extra code is annoying. This is just a tricky problem because TypeScript doesn’t know your conventions or config. That said, maybe we could let TypeScript know about our conventions and config to help it out a bit. Here are a few projects working on this problem:

  • routes-gen by Stratulat Alexandru and remix-routes by Wei Zhu both generate types based on your Remix config/conventional routes (we’ll talk about this more later)
  • (WIP) TanStack Router by Tanner Linsley which ensures all utilities (like useParams) have access to the routes you’ve defined (effectively informing TypeScript of your config which is another workaround we’ll get to).

This is just an example from a router perspective, which is addressing the URL boundary to your application, but the idea of teaching TypeScript your conventions can apply to other areas as well.

Let’s check out another more complex example of a type guard:

type Ticket = {
  workshopId: string
  attendeeId: string
  discountCode?: string

// this is a type guard function
function isTicket(ticket: unknown): ticket is Ticket {
  return (
    Boolean(ticket) &&
    typeof ticket === 'object' &&
    typeof (ticket as Ticket).workshopId === 'string' &&
    typeof (ticket as Ticket).attendeeId === 'string' &&
    (typeof (ticket as Ticket).discountCode === 'string' ||
      (ticket as Ticket).discountCode === undefined)

const ticket = JSON.parse(localStorage.get('ticket'))
//    ^?  any
if (isTicket(ticket)) {
  // now we know!
} else {
  // handle the case the data is not a ticket

Seems like a lot of work, even for a relatively simple type. Imagine a more real-world complex type! If you’re doing this a lot, you may find a tool like zod handy!

import { z } from "zod"

const Ticket = z.object({
  workshopId: z.string(),
  attendeeId: z.string(),
  discountCode: z.string().optional()
type Ticket = z.infer<typeof Ticket>

const rawTicket = JSON.parse(localStorage.get('ticket'))
const result = Ticket.safeParse(rawTicket);
if (result.success) {
  const ticket = result.data
  //    ^? Ticket
} else {
  // result.error will have a handy informative error

My biggest concern with zod (why I don’t use it all the time) is the bundle size is quite large (42KB uncompressed at the time of this writing). But if you’re using it only on the server or you’re going to benefit a lot from it then it could be worth that cost.

One tool taking advantage of zod to help a great deal with fully typed web apps is tRPC which shares types defined with zod on the server to client-side code to give you type safety across the network boundary. As I use Remix I don't personally use tRPC (though you definitely can if you'd like), but if I weren't using Remix, I would 100% be looking into using tRPC for this capability.

Type guards/assertion functions is also the approach you’d want to use to handle FormData for your forms. Personally, I’m really enjoying using remix-validity-state, but the idea is the same: Code that actually checks the types at runtime and gives you type safety around the boundaries of your app.

Type Generation

I talked about two tools that generate types for your Remix conventional routes, those are a form of type generation to help solve the problem of end-to-end type safety. Another popular example of this workaround is Prisma (my favorite ORM). Many GraphQL tools do this as well. The idea is to allow you to define a schema and Prisma ensures that your database tables match that schema. Then it also generates TypeScript type definitions that match the schema as well. Effectively keeping the types and database in sync. For example:

const workshop = await prisma.user.findFirst({
   // ^? { id: string, title: string, date: Date } 🎉
  where: { id: workshopId },
  select: { id: true, title: true, date: true },

Any time you make a change to your schema and create a migration script, prisma will update the types that it has in your node_modules directory so when you interact with the prisma ORM, the types match the current schema. Here’s a real-world example from the User table at kentcdodds.com:

model User {
  id           String     @id @default(uuid())
  createdAt    DateTime   @default(now())
  updatedAt    DateTime   @updatedAt
  email        String     @unique(map: "User.email_unique")
  firstName    String
  discordId    String?
  convertKitId String?
  role         Role       @default(MEMBER)
  team         Team
  calls        Call[]
  sessions     Session[]
  postReads    PostRead[]

And this is what’s generated from that:

 * Model User
export type User = {
  id: string
  createdAt: Date
  updatedAt: Date
  email: string
  firstName: string
  discordId: string | null
  convertKitId: string | null
  role: Role
  team: Team

This gives a fantastic developer experience and serves as the starting point to my types that flow through my application on the backend.

The primary danger here is if the database schema and the data in the database somehow get out of sync. But I’ve yet to experience that with Prisma and I expect that to be quite rare, so I feel pretty confident in not adding assertion functions around my database interactions. However, if you’re not able to use a tool like Prisma or if you’re not the team responsible for the database schema, I suggest that you find a way to generate types for your database based on the schema because it’s just fantastic. Otherwise you may want to add assertion functions to your database query results.

Keep in mind we’re not just doing this to make TypeScript happy. Even if we didn’t have TypeScript, it would be a good idea to have some level of confidence that data going between a boundary in your application is what you expect it is. Remember:

Tweet from @kentcdodds: “TypeScript isn’t making your life worse. It’s just showing you how bad your life already is”  — Me, in a workshop right now eexplaining how annoying form elements are to get actual runtime safety.
loading image...

Helping TypeScript with Conventions/Config

One of the more challenging boundaries is the network boundary. Verifying what the server is sending your UI is tricky. fetch doesn’t have generic support, and even if it did you would be lying to yourself:

// this doesn't work, don't do it:
const data = fetch<Workshop>('/api/workshops/123').then(r => r.json())

Allow me to let you in on a dirty little secret about generics… Almost any function that effectively does this is probably a bad idea:

function getData<DataType>(one, two, three) {
  const data = doWhatever(one, two, three)
  return data as DataType // <-- this

Whenever you’re seeing as Whatever (a type cast), you should think: “That’s lying to the TypeScript compiler” which sometimes is what’s required to get the job done, but I don’t recommend doing this like the getData function above. You’ve got two choices:

const a = getData<MyType>() // 🤮
const b = getData() as MyType // 😅

In both cases you’re lying to TypeScript (and yourself), but in the first case you don’t know it! If you’re going to lie to yourself, you should at least know that you’re doing it.

So what do we do if we don’t want to lie to ourselves? Well, you could establish a strong convention for your fetching and then inform TypeScript of that convention. That’s what’s done with Remix. Here’s a quick example of that:

import type { LoaderArgs } from "@remix-run/node"
import { json } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
import { prisma } from "~/db.server"
import invariant from "tiny-invariant"

export async function loader({ params }: LoaderArgs) {
  const { workshopId } = params
  invariant(workshopId, "Missing workshopId")
  const workshop = await prisma.workshop.findFirst({
    where: { id: workshopId },
    select: { id: true, title: true, description: true, date: true },
  if (!workshop) {
    // handled by the Remix CatchBoundary
    throw new Response("Not found", { status: 404 })
  return json({ workshop })

export default function WorkshopRoute() {
  const { workshop } = useLoaderData<typeof loader>()
  //      ^? { title: string, description: string, date: string }
  return <div>{/* Workshop form */}</div>

The useLoaderData function is a generic which accepts a Remix loader function type and is able to determine all possible JSON responses (huge thank you to the creator of zod, Colin McDonnell, for this contribution). The loader runs on the server and the WorkshopRoute function runs on both the server and client, but getting those types across that network boundary just kind of happens thanks to the generic understanding Remix’s loader convention. Remix will ensure that the data returned from the loader will end up being returned by useLoaderData. All in one file. No API routes necessary 🥳.

If you’ve not experienced, you just gotta believe me that this is an amazing experience. Just imagine we decide we want to display the price field in our UI. That’s as simple as updating our select for the database query, and then we suddenly have that available in our UI code without changing anything else. Completely type safe! And if we decide we no longer need the decription we simply remove that from the select and we’ll get red squiggles (and type checking errors) everywhere we were using the description before, which helps with refactors.

All across the freaking network boundary.

You might have noticed that the date property in our UI code is a type string even though it’s actually a Date in the backend. This is because the data has to go through the network boundary and in the process everything gets serialized to a string (JSON doesn’t support Dates). The type utilities enforce this behavior which is stellar.

If you plan to display that Date, you probably should format it in the loader before sending it over to avoid timezone weirdness when the app is hydrated on the user’s machine. That said, if you don’t like this, you can use a tool like superjson by Matt Mueller and Simon Knott or remix-typedjson by Michael Carter to get those data types restored in the UI.

In Remix, we also get type safety with actions as well. Here’s an example of that as well:

import type { ActionArgs } from "@remix-run/node"
import { redirect, json } from "@remix-run/node"
import { useActionData, useLoaderData, } from "@remix-run/react"
import type { ErrorMessages, FormValidations } from "remix-validity-state"
import { validateServerFormData, } from "remix-validity-state"
import { prisma } from "~/db.server"
import invariant from "tiny-invariant"

// ... loader stuff here

const formValidations: FormValidations = {
  title: {
    required: true,
    minLength: 2,
    maxLength: 40,
  description: {
    required: true,
    minLength: 2,
    maxLength: 1000,

const errorMessages: ErrorMessages = {
  tooShort: (minLength, name) =>
    `The ${name} field must be at least ${minLength} characters`,
  tooLong: (maxLength, name) =>
    `The ${name} field must be less than ${maxLength} characters`,

export async function action({ request, params }: ActionArgs) {
  const { workshopId } = params
  invariant(workshopId, "Missing workshopId")
  const formData = await request.formData()
  const serverFormInfo = await validateServerFormData(formData, formValidations)
  if (!serverFormInfo.valid) {
    return json({ serverFormInfo }, { status: 400 })
  const { submittedFormData } = serverFormInfo
  //      ^? { title: string, description: string }
  const { title, description } = submittedFormData
  const workshop = await prisma.workshop.update({
    where: { id: workshopId },
    data: { title, description },
    select: { id: true },
  return redirect(`/workshops/${workshop.id}`)

export default function WorkshopRoute() {
  // ... loader stuff here
  const actionData = useActionData<typeof action>()
  //    ^? { serverFormInfo: ServerFormInfo<FormValidations> } | undefined
  return <div>{/* Workshop form */}</div>

Again, whatever our action returns ends up being the type (serialized) that our useActionData references. In this case, I’m using remix-validity-state which will have type safe properties as well. Also the submitted data is safely parsed by remix-validity-state following the schema I provided, so the submittedFormData type has all my data parsed and ready to go. There are other libraries for this, but the point is, with a few simple utilities we can get some fantastic type safety across boundaries and increase our confidence in shipping. Well, the API for the utilities is simple. Sometimes the utilities themselves are pretty darn complex 😅

It should be mentioned that this works throughout other Remix utilities as well. The meta export can be fully typed, as can useFetcher, and useMatcher. It’s a glorious world my friends.

Seriously, that loader thing is just the bee’s knees. I mean, heck, watch this!

Across the network typesafety

In one file. Heck yes 🔥


The point I’m trying to make here is that type safety is something that’s not only valuable, but achievable across boundaries end to end. That last loader example goes all the way from the database to the UI. That data is type safe from databasenodebrowser and it makes me incredibly productive as an engineer. Whatever project you’re working on, think about how you can drop a few as Whatever type cast lies and change it to more true type safety using some of the suggestions I’ve provided here. I think you’ll thank yourself later. It’s definitely worth the effort!

If you want to try out an example of this, checkout kentcdodds/fully-typed-web-apps-demo.

And before anyone asks, I will 100% be teaching all of these methods in EpicWeb.dev. Sign up below to receive updates!

Share this article with your friends

Kent C. Dodds

Written by Kent C. Dodds

Kent is a world renowned speaker, teacher, and trainer and he's actively involved in the open source community as a maintainer and contributor of hundreds of popular npm packages. He is the creator of EpicReact.Dev and TestingJavaScript.com.

Stay up to date

Subscribe to the newsletter to stay up to date with articles, courses and much more!

I respect your privacy. Unsubscribe at any time.