TypeScript Strict Mode
New TypeScript projects created with yarn create cedar-app have strict mode turned on.
web/tsconfig.json, api/tsconfig.json and scripts/tsconfig.json all set "strict": true, and the code Cedar's generators and setup commands write type-checks in strict mode.
Enabling strict mode in an existing project
Projects created before Cedar v8 keep whatever their tsconfig files say, and that's usually no strict mode.
To turn it on, set strict to true in web/tsconfig.json and api/tsconfig.json, and if you're using scripts in scripts/tsconfig.json:
{
"compilerOptions": {
"noEmit": true,
"allowJs": true,
"strict": true
// ...
}
// ...
}
Cedar's type generator behaves a bit differently in strict mode, so make sure to generate types after changing the setting:
yarn cedar g types
Then run yarn cedar type-check to see what needs updating.
Code you've written yourself, and code generated by older versions of Cedar, may need some changes. The sections below cover the most common ones.
Strict mode is a type-checking setting only. Code that Cedar generates into a project without strict mode works the same, and you can turn strict mode off again by removing the strict option.
Writing code in strict mode
These are the places where strict mode most often needs a bit of extra code in a Cedar project.
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,
nullis a value, andundefinedmeans "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 may have to make sure nulls are converted to undefined from your GraphQL mutation inputs. You'll have to think carefully about the behaviour you want - if the client is expected to send null, and you expect those fields to be set to null, you can make the field nullable in your Prisma schema. Sending a null will mean removing that value, sending undefined will mean that the field won't be updated.
Every field in a generated Update<Model>Input type is nullable, because GraphQL can't express "optional, but not null".
For fields that are required in your Prisma schema you probably want to convert null to undefined, which leaves the field unchanged.
That's what the services Cedar generates do:
export const updateUser: MutationResolvers['updateUser'] = ({ id, input }) => {
return db.user.update({
data: {
...input,
// Every field in the GraphQL input is nullable, but Prisma doesn't
// accept null for required fields. undefined leaves the field unchanged
email: input.email ?? undefined,
},
where: { id },
})
}
Nullable fields in your Prisma schema can keep the null, as it clears the field.
The exception is Json fields: Prisma uses Prisma.DbNull (or Prisma.JsonNull) instead of null, so generated services pass Prisma.DbNull when the input is null:
import { db, Prisma } from 'src/lib/db'
export const updateWidget: MutationResolvers['updateWidget'] = ({
id,
input,
}) => {
return db.widget.update({
data: {
...input,
meta: input.meta === null ? Prisma.DbNull : input.meta,
},
where: { id },
})
}
@cedarjs/api also has a removeNulls utility that turns every null into undefined, but its return type isn't tied to your input type, so TypeScript won't check what you pass to Prisma.
Relation resolvers in services
Let's say you have a Post model in your schema.prisma that has an author field which is a relation to the Author model. It's a required field.
This is what the Post model's SDL would probably look like:
export const schema = gql`
type Post {
id: Int!
title: String!
author: Author! # 👈 This is a relation; the `!` makes it a required field
authorId: Int!
# ...
}
When you generate SDLs or Services, the resolver for author is generated at the bottom of posts.ts on the Post object:
export const Post: PostRelationResolvers = {
author: (_obj, { root }) => {
return db.post.findUniqueOrThrow({ where: { id: root.id } }).author()
},
}
Post.author can't be null (it's required in the Prisma schema), so the resolver must not return null.
Prisma's fluent API (.author()) on findUnique always returns a nullable value, because the post itself might not exist.
findUniqueOrThrow throws instead, so .author() returns a non-nullable Author.
root is the post that was already found, so the query finds it unless the post was just deleted, for example when author is selected from a deletePost mutation.
Then the resolver throws, which matches what GraphQL does for a null in a required field.
Optional relations (author: Author) keep findUnique, so their resolvers return null when the post is gone.
If your services were generated by an older version of Cedar, they use findUnique for every relation. Switch the required and list relations to findUniqueOrThrow when you turn on strict mode.
If the relation truly is required, it may make more sense to include author in your post Service's Prisma query and modify the Post.author resolver accordingly:
export const post: QueryResolvers['post'] = ({ id }) => {
return db.post.findUnique({
include: {
author: true,
},
where: { id },
})
}
export const Post: PostRelationResolvers = {
author: (_obj, { root }) => {
if (root.author) {
return root.author
}
return db.post.findUniqueOrThrow({ where: { id: root.id } }).author()
},
}
This will also help Prisma make a more optimized query to the database, since every time a field on Post is requested, the post's author is too! The tradeoff here is that any query to Post (even if the author isn't requested) will mean an unnecessary database query to include the author.
Roles checks for CurrentUser in src/lib/auth
When you setup auth, Cedar includes some template code for handling roles with the hasRole function.
While Cedar 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
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 currentUserRoles === 'string') {
// roles to check is an array, currentUser.roles is a string
return roles.some((allowedRole) => currentUserRoles === 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
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
For auth providers other than dbAuth, the shape of the decoded token depends on your account settings (if you include roles or other metadata), so Cedar can't know it at setup time.
The generated getCurrentUser types decoded as Decoded from @cedarjs/api, which is Record<string, unknown> | null. That type-checks, but every claim on the token is unknown.
To get more precise types, type decoded yourself. The comments above the getCurrentUser function describe its parameters.
import type { AuthContextPayload } from '@cedarjs/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]
) => {
// ...
}