Skip to main content
Version: Canary

TypeScript Strict Mode

Looks like you're ready to level up your TypeScript game! Redwood supports strict mode, but doesn't enable it by default. While strict mode gives you a lot more safety, it makes your code a bit more verbose and requires you to make small manual changes if you use the generators.

Enabling strict mode

Enable strict mode by setting strict to true in web/tsconfig.json and api/tsconfig.json, and if you're using scripts in scripts/tsconfig.json:

web/tsconfig.json, api/tsconfig.json, scripts/tsconfig.json
{
"compilerOptions": {
"noEmit": true,
"allowJs": true,
"strict": true,
// ...
}
// ...
}

Redwood's type generator behaves a bit differently in strict mode, so now that you've opted in, make sure to generate types:

yarn rw g types

Manual tweaks to generated code

Now that you're in strict mode, there are some changes you need to make to get rid of those pesky red underlines!

Service functions & tests

By default, Redwood's generators assume that Service functions don't require any parameters. For example, in the tutorial, the posts Service function generated by the scaffold command doesn't specify any parameters:

src/services/posts/posts.test.ts
export const posts: QueryResolvers['posts'] = () => {
return db.post.findMany()
}

While this is true, in the strictest sense, all GraphQL resolvers (and most of your Service functions do end up becoming GraphQL resolvers) take some sort of input, so you'll need to modify the calls to your Service functions that have been typed with QueryResolvers or MutationResolvers:

src/services/posts/posts.test.ts
describe('posts', () => {
scenario('returns all posts', async (scenario: StandardScenario) => {
const result = await posts() // 🛑 error
const result = await posts({}) // ✅

expect(result.length).toEqual(Object.keys(scenario.post).length)
})
})
Why this happens in strict mode

In strict mode, Redwood generates different types for QueryResolvers and MutationResolvers, where the first argument—args—is no longer optional.

We realize that this isn't a perfect solution, but the tension comes from the fact that we can't define different types for when a Service function is called from inside GraphQL (as a resolver) versus when it's called from anywhere else.

Returning Prisma's findUnique operation in Services

In strict mode, TypeScript becomes a lot more pedantic about null checks. One place where you'll encounter this in particular is when returning Prisma's findUnique operation in Service functions.

The tension here is that Prisma returns promises in the form of Promise<Model | null>, but the resolver types expect it in the form of Promise<Model> | Promise<null>. At runtime, this has no effect. But the TS compiler needs to be told that it's okay:

export const post: QueryResolvers['post'] = ({ id }) => {
return db.post.findUnique({
where: { id },
}) as Promise<Post> | Promise<null>
}

null and undefined in Services

One of the challenges in the GraphQL-Prisma world is the difference in the way they treats optionals:

  • for GraphQL, optional fields can be null
  • but For Prisma, null is a value, and undefined means "do nothing"

This is covered in detail in Prisma's docs, which we strongly recommend reading. But the gist of it is that, for Prisma's create and update operations, you have to make sure nulls are converted to undefined from your GraphQL mutation inputs. One way to do this is to use the dnull package:

yarn workspace api add dnull
api/src/services/users.ts
import { dnull } from "dnull"

export const updateUser: MutationResolvers["updateUser"] = ({ id, input }) => {
return db.user.update({
data: dnull(input),
where: { id },
})
}

Roles checks for CurrentUser in src/lib/auth

When you setup auth, Redwood includes some template code for handling roles with the hasRole function. While Redwood does runtime checks to make sure it doesn't access roles if it doesn't exist, TypeScript in strict mode will highlight errors, depending on whether you are returning roles, and whether those roles are string or string[]

export const hasRole = (roles: AllowedRoles): boolean => {
if (!isAuthenticated()) {
return false
}

const currentUserRoles = context.currentUser?.roles
// Error: Property 'roles' does not exist on type '{ id: number; }'.ts(2339)

You'll have to adjust the generated code depending on your User model.

Example code diffs

A. If your project does not use roles

If your getCurrentUser doesn't return roles, and you don't use this functionality, you can safely remove the hasRole function.

B. Roles on current user is a string

Alternatively, if you define the roles as a string, you can remove the code that does checks against Arrays

api/src/lib/auth.ts
export const hasRole = (roles: AllowedRoles): boolean => {
if (!isAuthenticated()) {
return false
}

const currentUserRoles = context.currentUser?.roles

if (typeof roles === 'string') {
- if (typeof currentUserRoles === 'string') {
return currentUserRoles === roles
- }
}

if (Array.isArray(roles)) {
- if (Array.isArray(currentUserRoles)) {
- return currentUserRoles?.some((allowedRole) =>
- roles.includes(allowedRole)
- )
- } else if (typeof context?.currentUser?.roles === 'string') {
// roles to check is an array, currentUser.roles is a string
return roles.some(
(allowedRole) => context.currentUser?.roles === allowedRole
)
- }
}

// roles not found
return false
}

C. Roles on current user is an Array of strings

If in your User model, roles are an array of strings, and can never be just a string, you can safely remove most of the code

api/src/lib/auth.ts
export const hasRole = (roles: AllowedRoles): boolean => {
if (!isAuthenticated()) {
return false
}

const currentUserRoles = context.currentUser?.roles

if (typeof roles === 'string') {
- if (typeof currentUserRoles === 'string') {
- return currentUserRoles === roles
- } else if (Array.isArray(currentUserRoles)) {
// roles to check is a string, currentUser.roles is an array
return currentUserRoles?.some((allowedRole) => roles === allowedRole)
- }
}

if (Array.isArray(roles)) {
- if (Array.isArray(currentUserRoles)) {
return currentUserRoles?.some((allowedRole) =>
roles.includes(allowedRole)
)
- } else if (typeof currentUserRoles === 'string') {
- return roles.some(
- (allowedRole) => currentUserRoles === allowedRole
- )
}
}

// roles not found
return false
}

getCurrentUser in api/src/lib/auth.ts

Depending on your auth provider—i.e., anything but dbAuth—because it could change based on your account settings (if you include roles or other metadata), we can't know the shape of your decoded token at setup time. So you'll have to make sure that the getCurrentUser function is typed.

To help you get started, the comments above the getCurrentUser function describe its parameters' types. We recommend typing decoded without using imported types from Redwood, as this may be a little too generic!

api/src/lib/auth.ts
import type { AuthContextPayload } from '@redwoodjs/api'

// Example 1: typing directly
export const getCurrentUser: CurrentUserFunc = async (
decoded: { id: string, name: string },
{ token, type }: { token: string, type: string },
) => {
// ...
}

// Example 2: Using AuthContextPayload
export const getCurrentUser: CurrentUserFunc = async (
decoded: { id: string, name: string },
{ token, type }: AuthContextPayload[1],
{ event, context }: AuthContextPayload[2]
) => {
// ...
}