DEV Community

David Judge
David Judge

Posted on • Originally published at blog.rolloutit.net

The Missing Type-Safety for Full-Stack

My Journey

I have come a long way to get here. I have always wanted to share types between the frontend and the backend. I was never satisfied with existing solutions such as using Swagger. In every greenfield project, I built a framework for sharing endpoint request and response types with proper, type-safe error handling. Once I got far enough, I wrote an API client generator with React integration and so on. And I was constantly searching for better alternatives. I found a Chinese library that could share auto-inferred response types. Later, I found tRPC, which was much better. It was something I had already imagined. I got excited, tried it, and unfortunately, I got disappointed because my existing solutions were better in many ways. Conceptually, it was not built upon the principles I had started to believe in during my career:

  • I use discriminated unions a lot.
  • I don’t use “throw” for user errors.
  • I avoid “any” types.
  • I don’t like type casting.
  • I prefer pure functions without side effects and mutations.
  • I prefer to pass dependencies manually and explicitly instead of using an injector.
  • And so on …

I have my own reasons, but these are my preferences, and I don’t like it when a framework doesn’t let me do the job the way I would like to. tRPC didn’t let me. But hey, that’s a good thing because it led me to start my own project: Cuple, which I managed to finish quite quickly, and we are already using it in production! I managed to make it really great to work with. And it doesn’t get in your way. I’ve been using it for a while now, and I can’t express how much pride I have in it. Check out this example:

  const authLink = builder
    .headersSchema(
      z.object({
        authorization: z.string().startsWith("Bearer "),
      }),
    )
    .middleware(async ({data}) => {
      const token = data.headers.authorization.replace("Bearer ", "");
      try {
        const user = await auth.verifyIdToken(token);
        return {
          next: true as const,
          authData: {
            firebaseUserId: user.uid,
          },
        };
      } catch (e) {
        return apiResponse("forbidden-error", 403, {
          next: false as const,
          message: "Bad token",
        });
      }
    })
    .buildLink();
Enter fullscreen mode Exit fullscreen mode

This is an authLink which contains schema validation for the headers (yes, you can have type-safe headers!) and middleware that shares authData if the user is logged in.

One of the biggest problems with plain express, is that it is not built for typescript. With plain express you mutate the request in the middleware if you want to share data down the line. Mutation is unsafe, and not type-checkable. I solved this by having an endpoint builder. This might look strange at first, but trust me, this works really well!

You just have to chain this link wherever you want. See:

{ /* ... */
addUser: builder
  .chain(authLink)
  .bodySchema(
    z.object({
      email: z.string().email(),
      firstName: z.string().min(1),
      lastName: z.string(),
      industryId: z.number(),
      organization: z.union([
        z.object({
          action: z.literal("Join"),
          code: z.string(),
        }),
        z.object({
          action: z.literal("Create"),
          name: z.string(),
        }),
      ]),
    }),
  )
  .middleware(async ({data}) => {
    const org = data.body.organization;
    if (org.action === "Join") {
      const payload = invitationService.parseCode(org.code);
      return {
        next: true,
        invitationData: {
          orgId: payload.orgId,
        },
      };
    }
    return {
      next: true,
    };
  })
  .post(async ({data}) => {
    const org = data.body.organization;
    await userService.addUser({
      email: data.body.email,
      firebaseUserId: data.authData.firebaseUserId, // coming from authLink 
      firstName: data.body.firstName,
      lastName: data.body.lastName,
      industryId: data.body.industryId,
      organization:
        org.action === "Join"
          ? {
              action: "Join",
              id: data.invitationData!.orgId, // coming from the middleware above
            }
          : {
              action: "Create",
              name: org.name,
            },
    });

    return success({message: "User has been created successfully"});
  }),
}
Enter fullscreen mode Exit fullscreen mode

There are more things going on here.

You can keep adding middlewares, schema validations for query, body, headers, even for path parameters, if you need custom path.

Every step’s exposed data is type-safely accessible. Check this out:

property suggestions

Would you like that much type safety in your project? Of course you would!

It’s done right on client-side too!

export const client = createClient<Routes>({
  path: prefix + '/api/rpc',
});

const response = await client.user.addUser.post({
  headers: {
    authorization: `Bearer ${localStorage.getItem('firebaseIdToken')}`,
  },
  body: {
    email: 'some@body',
    firstName: 'Test',
    lastName: 'Test',
    industryId: 1,
    organization: {
      action: 'Create',
      name: 'My Org',
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Remember we need headers because of the authLink? Yes, it’s type-checked client-side! But why not use a new client that includes that automatically?

export const client = createClient<Routes>({
  path: prefix + '/api/rpc',
});

export const authedClient = client.with(() => {
  return {
    headers: {
      authorization: `Bearer ${localStorage.getItem('firebaseIdToken')}`,
    },
  };
});

const response = await authedClient.user.addUser.post({
  body: {
    email: 'some@body',
    firstName: 'Test',
    lastName: 'Test',
    industryId: 1,
    organization: {
      action: 'Create',
      name: 'My Org',
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

And the response type is a discriminated union of the possible responses auto-inferred from the endpoint:

const response:
    | ZodValidationError<{ authorization: string; }>
    | { result: "success"; statusCode: 200; message: string; }
    | { result: "unexpected-error"; statusCode: 500; message: string; }
    | { result: "forbidden-error"; statusCode: 403; message: string; }
    | ZodValidationError<{
        organization: {
            code: string;
            action: "Join";
        } | {
            action: "Create";
            name: string;
        };
        email: string;
        firstName: string;
        lastName: string;
        industryId: number;
    }>
Enter fullscreen mode Exit fullscreen mode

Conclusion

If I join a project, I would love to see Cuple being used. I want to work with it, and I am confident that I will use this for future projects. I hope you enjoyed reading.

Open in StackBlitz

Quick start: https://github.com/fxdave/react-express-cuple-boilerplate

More examples: https://github.com/fxdave/cuple/tree/main/test/src/examples/auth

Project: https://github.com/fxdave/cuple

Top comments (1)

Collapse
 
linkee12 profile image
Kálmán Bíró

It's usefull! thanks