Skip to content

E2E Typesafety in Nuxt Fullstack Application

Read the original post here

Introduction

End to end (e2e) type safety is the method of sharing your types between your server and client.

This article will walk you through how to implement e2e type safety using Nuxt's built-in client/server relationships and then I will show you an alternative way of achieving an even more streamlined implementation of this using one of Anthony Fu's libraries.

This article assumes you have a basic understanding of Typescript as well as a Nuxt application set up.

Native Implementation

Let's start on the server by creating an api route at server/api/getUser.ts that returns a basic object.

ts
// api/getUser.ts
export default defineEventHandler(() => {
  return { name: 'foo' }
})

Now, we can consume this on the client using Nuxt's useFetch composable.

ts
// app.vue
<script setup lang="ts">
const { data: userData } = await useFetch('/api/getUser')
</script>

<template>
  <pre>
    {{ userData }}
  </pre>
</template>

This falls short in typing. I had hoped it would implicitly provide the data structure that the server is providing, but instead, it's implicitly unknown.

unknown implicit typing|500

We can adjust this, and explicitly type it on the client side:

ts
// app.vue
<script setup lang="ts">

interface User {
  name: string
}

const { data: userData } = await useFetch<User>('/api/getUser')
</script>

<template>
  <pre>
    {{ userData }}
  </pre>
</template>

The issue here is that we can type this any way we want.

ts
// app.vue
<script setup lang="ts">
interface Dog {
  breed: string
}

const { data: userData } = await useFetch<Dog>('/api/getUser')
</script>

<template>
  <pre>
    {{ userData }}
  </pre>
</template>

In this example, I am typing the response as a Dog type that expects a breed, which shouldn't be possible (because the server will never provide breed in this case).

What we can do instead is create an explicit User type:

ts
// types/user.ts
export interface User {
  name: string
}

and import it into both the server and client implementations:

ts
// api/getUser.ts
import { User } from '~/types/user'

export default defineEventHandler(() => {
  return { name: 'foo' } as User
})
ts
// app.vue
<script setup lang="ts">
import type { User } from './types/user'

const { data: userData } = await useFetch<User>('/api/getUser')
</script>

<template>
  <pre>
    {{ userData }}
  </pre>
</template>

From what I can tell, this is the best way of handling this typing in a Nuxt application natively. It still runs into the issue of being able to change the type on the client side.

Problems with this approach

  1. I can still explicitly set the expected response to a different type from what is sent from the server.

i.e. Server sends a User type and the client explicitly sets it to Dog.

  1. useFetch relies on strings as a parameter to connect to the specific API route it's interacting with. In our case, /api/getUser.

Reusing strings throughout the application is a code smell. If we rename the api route to /api/getUniqueUser, we must go through and find every use of api/getUser and change this.

Using an incorrect string in our useFetch composable will not yell at us, and carry on as if nothing happened. It's the same with functions, but using a function with a name that doesn't exist will break the application.

Preferred Implementation

I prefer to keep the types as a single source of truth.

If my server is using the User type, my client should have no other option but to use the User type when interacting with these server functions.

Anthony Fu built a Nuxt library called nuxt-server-fn that solves the issues described above.

Instead of relying on Nuxt's defineEventHandler function, we can create a custom function. We can create a function in the server/functions directory like so:

ts
// server/functions/foo.ts
import { User } from '~/types/user'

export function getUser(): User {
  return { name: 'abc' }
}

And this is now callable on the client-side:

ts
// app.vue
<script setup lang="ts">
const { getUser } = useServerFunctions()
const userResponse = await getUser()
</script>

<template>
  <pre>
    {{ userResponse }}
  </pre>
</template>

We no longer have to explicitly type anything on the client-side.

nuxt-server-fn-client|500

And now, when we hover over the response, it has the User type attached to it.

This also solves the issue of renaming. We no longer rely on passing strings around, and instead rely on the function names.

If we were to change the server implementation to getUniqueUser by renaming the function, Typescript will yell at us on the client and not allow the application to build.

Conclusion

End-to-end type safety is a powerful tool that enables developers to have higher confidence in the data they are working with.

The native implementation that Nuxt provides is a good starting point, but has a few smells (explicit client-side typing and reliance on strings) that made me look elsewhere. I believe nuxt-server-fn solves these issues elegantly and is worth using in future full stack Nuxt applications.

Resources