TypeScript in Remix Isn't Very Type Safe. Part 1: Routing

TypeScript in Remix Isn't Very Type Safe. Part 1: Routing

I've been trying out Remix and I think it's an excellent front and back end TypeScript framework (I will refer to this as full stack TypeScript from now on, [1]). Unfortunately, I think it precludes one of the biggest advantages of full stack TypeScript: enforcing correctness by sharing types between the front and back end. This isn't a slight against Remix, as I said I really do think it's very good, because as far as I can tell almost no one is utilising full stack TypeScript like this.

To be more specific there are three places where I think Remix gives up on typing and opens the door to different parts of the codebase disagreeing with each other on what type a value should be, thus opening the door to runtime errors that could have been caught at build time:

  • paths: currently there's nothing stopping you from creating a Link to a local route that hasn't been defined. Yet it is possible in TypeScript projects to ensure that Links that point to undefined routes cause a build error (e.g. with: static-path)
  • loaders: the return type of a LoaderFunction tells us nothing about the data being returned. Which should not be surprising because useLoaderData returns an any and relies on the user typing the value themselves and making sure it never gets out of sync with the value being returned from loader
  • actions: this is the one that keeps me up at night. In short there is no way to know what type an action expects to receive, nor what type the action is going to return. But a proper explanation of the shortcomings would take an entire blog post. One which I intend to write soon

I believe it is possible to make a full stack TypeScript framework that eliminates all of these points of failure. If that seems hard to believe then Gary Bernhardt has an excellent video explaining how he can make a change in the database schema of his app Execute Program and then follow the type errors all the way through the server and client to the UI until everything is consistent.

Unfortunately I think that Remix's convention over configuration approach makes it very difficult to lean on existing TypeScript tooling to enable this. This blog post explains why it is hard to ensure the correctness of paths in Remix, and an approach to fixing this. Future posts will do the same for loaders and actions.

Routing

It's worth reading the documentation for static-path to get a thorough understanding of how static paths can work, but the relevant summary of what static-path does is:

  • You define which paths exist.
  • You pass those paths' patterns to your router of choice.
  • You link to those paths.

By using the list of paths you create in the first bullet point as the source of truth you can then make sure that you only link to paths that exist there and that they have parameters that make sense when you do. However one problem with this approach is that there's nothing guaranteeing that you implement a route for every path that you define. This means that passing a "correct" path to a link could link you to a 404 page!

I actually think Remix could both fix this shortcoming and reduce the amount of boilerplate you have to write! Currently Remix uses the file system to define routes, for example if src/routes/about default exports an About component, then navigating toexample.com/about will render the About component. It's hard to see how a user-defined repository of valid paths can have any relation to the structure of the src/routes folder that can guarantee correctness.

However, if we treat src/routes as the source of truth for paths, then the Remix dev server could watch src/routes and generate a src/paths.ts so that we could not only have a list of valid paths, we could guarantee that each of those paths had a route associated with it! For a file structure that looks like this:

src/
├─ routes/
│  ├─ index.ts
│  ├─ user/
│  │  ├─ :id.ts
│  ├─ about.ts

src/paths.ts could look like this:

// This is an automatically generated file. Do not edit manually.

import { path } from "static-path";

export const index = path("/");

export const about = path("/about");

export const user = path("/user/:id");

Then when you want to create a link to a user that could look something like this:

import * as paths from "#/paths.ts";
import { User } from "#/types.ts";

export function LinkToUser(user: User) {
  // note that passing anything other than type { id: string } to paths.user causes a type error
  return <a href={paths.user({ id: user.id })}>
      {user.username}
    </a>
}

Perhaps better still src/paths.ts could look like this:

import { makeLinkComponent } from "remix";
import { path } from "static-path";

const paths = {
  index: path("/"),
  about: path("/about"),
  users: path("/user/:id"),
} as const;

export const Link = makeLinkComponent<typeof paths>();

which could then be used like this:

import { Link } from "#/paths.ts";

export function Example() {
  return <div>
    // All of these are valid
    <Link href="/about" /> 
    <Link href="/user/:id" params={{ id: "1" }} /> 
    <Link href="https://yolo.com" external />

    // All of these cause type errors
    <Link href="/does-not-exist" /> 
    <Link href="/user/:id" /> 
    <Link href="/user/:id" params={{}} />
    <Link href="/user/:id" params={{ id: "1", someOtherParam: "some value"}} />
  </div>;
}

Which I think is a significant improvement on the approaches of both static-path and Remix. If you've followed along so far and this sort of type paranoia appeals to you, you can sign up to my mailing list at the top of this page and get notified by email when my next blog post, on Remix's loaders, comes out.

[1] arguably this is a misnomer as it says nothing of the database layer