DEV Community

Cover image for End to End Typesafe Application
André Gomes
André Gomes

Posted on • Edited on

End to End Typesafe Application

Typesafety

When working in a javascript environment, adding type safety to an application may be challenging. Of course we have typescript that makes the developper experience smoother but it is mostly usefull at build time and during developement, what about runtime?

Through this post i'll try to go over on how we can achieve this to have the best DX possible and help teams from back-end to front-end to share a common knowledge and avoid frictions.

JS Ecosystem

This has already been addressed by several communities, we can build our own tool or use existing tools like Yup / Zod that are schema validator !
Most of the time i've seen teams using it only in the front end for Form Validation, but taking a step back and looking at the possibility it offers, with the right implementation it allows us to have an E2E type safe applications.

Why use it ?

Having a typescript with types shared across an organization is really nice and give us more confidence in our codebase, but what if we want to control the data flow, have some decent alerting on why something failed on the client, where, and what was missing ?
Except from the stack trace when it crashes and we receive the error on sentry or some other alerting tool, it may be challenging !
What if on every data used on an application, everything was controlled, and when something fail our reporting tool would be capable to tell us exactly what failed and why and where ?

In my team we went from hundreds of daily errors / warning due to flaky types or things that were not supposed to happens because of a bad input to only few warning that we can tackle fast and efficiently.

Strucutre

Depending on your organization structure, there is several ways to implement it, i'll go through the most common ones, every team is different and have their own structure, so i'll write here about the polyrepo / monorepo approach.

The monorepo approach

Within an organization if the code if shared in a monorepo, creating a shared package within it, may be a handy solution.
You can then use it across your services and applications through an install command depending on the tool you're using (yarn, npm, lerna, nx etc...)

The polyrepo approach

If you're using multiple repositories in your organization you can publish your shared type safe schema on your private / public npm registry, and then use it in any repository that may be relevant.

Usage

Note: this works regardless of the framework or library you use, it is just a plain javascript library that is agnostic.

I'll be focusing on Zod but the idea is the same regardless of the library.
Let's take the example of an API that can edit a USER.

Shared

// In my schema folder
import z from 'zod';

export const ZUser = z.object({
  age: z.number(),
  createdAt: z.date(),
  id: z.string().min(1),
  email: z.string().email(),
  firstname: z.string().min(1),
  lastname: z.string().min(1),
  updatedAt: z.date(),
});

export const ZUserEditSchema = ZUser.omit({ createdAt: true, updatedAt: true, id: true });
export const ZUserIdParam = ZUser.pick({ id: true });

export type UserSchema = z.infer<typeof ZUser>;
export type UserEditSchema = z.infer<typeof ZUserEditSchema>;
export type UserIdParam = z.infer<typeof ZUserIdParam>;
Enter fullscreen mode Exit fullscreen mode

So what is going on here ?
With Zod we defined our schema !
Then we infer the schema definition to a typescript definition through the method z.infer.

Backend

import { ZodError } from 'zod';
import { UserIdParam, ZUser, ZUserIdParam } from '@mylib/schema';


app.put(
  '/user/{id}',
  async (
    req: Request<UserIdParam, UserEditSchema>,
    res: Response<{ outcome: string; reason?: string; error?: unknown; entity?: UserSchema }>,
  ) => {
    try {
      const safeParam = ZUserIdParam.parse(req.query);
      const safeUser = ZUserEditSchema.parse(req.body);
      const user = await updateUser(safeParam.id, safeUser);

      const safeUpdatedUser = ZUser.parse(user);

      return res.send({
        outcome: 'found',
        entity: safeUpdatedUser,
      });
    } catch (err) {
      reportToMyTool(err);

      res
        .send({
          outcome: 'notFound',
          reason: 'schemaValidationFailed',
          error: err as ZodError,
        })
        .status(404);
    }
  },
);
Enter fullscreen mode Exit fullscreen mode

In our API, we import the schema definition and the typescript definition that we then use to type our api interface and we leverage zod to validate our data.

There are among others 2 main usefull methods for it, here i am using the parse method that basically will throw an error if some types from the schema does not match.
Another common one is the safeParse that wont throw and let you handle as you want the types that failed.

So what we know here is that if the id or request body does not match the correct schema definition or if the user that we retrieve from the database also fail to match our schema it will throw an error!
Great ! What about the client side ?

Client

// request
async function updateUser(user: UserEditSchema, id: UserIdParam['id']) {
  try {
    const response = await fetch(`https://myendpoint/user/${id}`, { method: 'put' });
    return await response.json();
  } catch (err) {
    reportToMyTool(err);
  }
}

// Component
export const UpdateUserForm = ({ id }: { id: string }) => {
  const [user, setUser] = useState<Partial<UserEditSchema> | null>(null);

  // parse the user object before trying to make a request
  const onUpdateUserClick = useCallback(async () => {
    const safeUser = ZUserEditSchema.safeParse(user);
    if (safeUser.success) {
      await updateUser(user, id);
    } else {
      toast({
        type: 'warning',
        description: safeUser.error.errors.map((error) => error.message).join('\n'),
      });
    }
  }, [id, user]);

  const onChange = useCallback(
    (event: ChangeEvent<HTMLInputElement>) => {
      const { name, value } = event.target;
      setUser({
        ...user,
        [name]: value,
      });
    },
    [user],
  );

  const inputs = ['email', 'firstname', 'lastname', 'age'];

  return (
    <form>
      {inputs.map((name) => (
        <input name={name} key={name} onChange={onChange} />
      ))}
      <button onClick={onUpdateUserClick}>Submit</button>
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

Here we defined a component named UpdateUserForm, it has some inputs and a button to make a request to update the form.
In the function attached to the button, it will first parse our user object, if all the required data are ok it will then trigger a request on our backend, otherwise it will show a message to the end user notifying them on what went wrong and which field is missing.

Last thoughts

This example just go through backend + front end communication, but you can also use it on your client side data, like states and others... !

I hope you enjoyed the article, this is my first time going through this, would love feedbacks and comments !
If you liked it, hit me up, i'll be thinking about the next subject to talk about !

Cheers !

Top comments (0)