- 后端
- 前端
- Web框架
- 开发工具
【免费下载链接】redwood
RedwoodGraphQL
Redwood 框架默认不开启 TypeScript 的strict严格模式,但一旦开启,你将获得更全面的类型安全,同时也会让生成器产出的代码暴露出大量类型报错。本文以 Redwood 官方文档 version-4.x/typescript/strict-mode.md 为主线,完整讲解在 Redwood 项目中启用严格模式的三个步骤、四类必须手工改造的生成代码场景,并结合本仓库的源码实现(removeNulls、AuthContextPayload、hasRole模板等)说明背后的原理,帮助你既看得懂报错,也改得对代码。
读完本文,你将掌握:如何为web、api、scripts三个tsconfig.json统一开启strict;如何区分 GraphQL 与 Prisma 对"可选值"的不同语义;如何修复 Services 中的null/undefined转换、关系解析器、hasRole角色检查与getCurrentUser类型标注这四类典型严格模式报错。
一、为什么 Redwood 默认不开启严格模式
Redwood 支持严格模式,但出于"开箱即用、上手平滑"的考虑,默认并不启用。正如 typescript/introduction.md 所介绍的,Redwood 的类型系统大量依赖代码生成器(SDL、Service、Cell、路由等都会生成对应类型),而在非严格模式下,生成代码可以容忍许多隐式的any、可空值等问题,用户几乎不需要手工修正。
严格模式的价值在于把整条链路的类型约束收紧:
strictNullChecks会把null/undefined变成一等公民,强制你处理它们;- 生成器产出的
MutationResolvers、PostRelationResolvers等类型会更加精确; - 类型错误会在编译期暴露,而不是在运行时才炸出来。
代价是代码会更"啰嗦",并且对生成代码需要做少量手工调整——这正是本文要解决的核心问题。
二、启用严格模式:三步配置 + 重新生成类型
1. 修改三份tsconfig.json
Redwood 项目中存在三份 TypeScript 配置文件,需要全部开启strict:
web/tsconfig.json(前端应用)api/tsconfig.json(API 侧)scripts/tsconfig.json(自定义脚本,仅在你有scripts目录时需要)
在每份文件的compilerOptions中加入"strict": true:
{ "compilerOptions": { "noEmit": true, "allowJs": true, // highlight-next-line "strict": true // ... } // ... }其中noEmit表示 Redwood 使用 Babel/Vite 负责转译、TypeScript 仅做类型检查,不会输出编译产物;allowJs允许混合使用 JavaScript 文件(这也是 Redwood 同时支持 JS/TS 项目的基础)。
2. 重新生成类型
Redwood 的类型生成器在严格模式下的行为略有不同——它会读取tsconfig.json中的strict设置,并据此调整生成的类型定义(例如部分生成类型会从宽松的any收敛为更精确的联合类型)。因此开启strict后,必须重新生成一次类型:
yarn rw g types这条命令会重新生成web/src/generated、api/src/generated等目录下的 GraphQL 类型、Prisma 类型与路由类型,确保新生成的类型与严格模式匹配。关于生成类型的更多细节可参考 typescript/generated-types.md。
三、严格模式下的四类典型报错与手工改造
开启严格模式后,生成器产出的代码会出现若干类型报错,下面按原文档的顺序逐一解决。
1. Services 中的null与undefined:GraphQL 与 Prisma 的语义鸿沟
这是 GraphQL + Prisma 世界最经典的坑:两者对"可选值"的语义完全不同。
- 对GraphQL而言,可选字段的值可以是
null; - 对Prisma而言,
null是一个"真实的值"(意味着把字段置空),而undefined才表示"不处理这个字段"。
因此,在 Prisma 的create和update操作中,来自 GraphQL mutation 的input里那些null,通常需要被转换成undefined。官方文档 Prisma: null and undefined 对这一差异有详细说明,核心结论如下:
- 如果客户端预期发送
null,并且你希望这些字段被真正置为NULL,可以在 Prisma schema 中把字段声明为可空(nullable),此时发送null意味着清除该值; - 如果发送
undefined,则该字段不会被更新(保持原值)。
绝大多数场景下,你希望的是"客户端没传的字段不要动",即把null转换为undefined。Redwood 在@redwoodjs/api包中提供了现成的工具函数removeNulls来完成这件事:
// highlight-next-line import { removeNulls } from '@redwoodjs/api' export const updateUser: MutationResolvers['updateUser'] = ({ id, input }) => { return db.user.update({ // highlight-next-line data: removeNulls(input), where: { id }, }) }源码级原理:removeNulls是如何实现的
从源码看,removeNulls定义在 packages/api/src/transforms.ts 中(源码注释表明它与 npm 上的dnull包等价,只是后者存在导入问题才自研实现):
export const removeNulls = (input: Record<number | symbol | string, any>) => { for (const key in input) { if (input[key] === null) { input[key] = undefined } else if ( typeof input[key] === 'object' && !(input[key] instanceof Date) // dates are objects too ) { // Note arrays are also typeof object! input[key] = removeNulls(input[key]) } } return input }实现要点:
- 原地修改:直接遍历并改写传入对象的属性,把值为
null的键改成undefined; - 递归处理嵌套:因为数组和普通对象都满足
typeof === 'object',函数会对嵌套对象/数组递归调用自身,深度清理整个输入结构; - 跳过
Date实例:Date也是对象,但它是合法值,不能被误当作嵌套结构递归处理。
对应的单元测试位于 packages/api/src/tests/transforms.test.ts,可作为行为契约参考。
2. Services 中的关系解析器(Relation Resolvers)
假设schema.prisma中有一个Post模型,其author字段是必填的关系字段,指向Author模型。生成的 SDL 大致如下:
export const schema = gql` type Post { id: Int! title: String! // highlight-next-line author: Author! # 👈 This is a relation; the `!` makes it a required field authorId: Int! # ... } `生成 SDL 或 Service 时,生成器会在post.service.ts底部为Post对象生成author的关系解析器。问题在于:Prisma 的findUnique返回值永远是可空的(数据库里可能查不到对应记录),而Post.author在 schema 里被声明为必填、不可为null。在严格模式下,这一矛盾会直接变成类型报错,需要你手动改造解析器,通常有两种方案。
方案一:覆盖类型(断言非空)
// Option 1: Override the type // The typecasting here is OK. `root` is the post that was _already found_ // by the `post` function in your Services, so `findUnique` will always find it! export const Post: PostRelationResolvers = { author: (_obj, { root }) => db.post.findUnique({ where: { id: root?.id } }).author() as Promise<Author>, // 👈 }这里用类型断言as Promise<Author>是安全的:root是外层postService 已经查询出来的记录,既然这条Post记录存在,它的必填关系author必然存在,findUnique一定能查到。
方案二:显式判空(更稳妥)
// Option 2: Check for null export const Post: PostRelationResolvers = { author: async (_obj, { root }) => { // Here, `findUnique` can return `null`, so we have to handle it: const maybeAuthor = await db.post .findUnique({ where: { id: root?.id } }) .author() // highlight-start if (!maybeAuthor) { throw new Error('Could not resolve author') } // highlight-end return maybeAuthor }, }优化建议:把关系直接包含进查询
如果该关系确实是必填的,更优雅的做法是把author直接include进postService 的 Prisma 查询中,再让关系解析器优先复用root上已经加载好的数据:
export const post: QueryResolvers['post'] = ({ id }) => { return db.post.findUnique({ // highlight-start include: { author: true, }, // highlight-end where: { id }, }) } export const Post: PostRelationResolvers = { author: async (_obj, { root }) => { // highlight-start if (root.author) { return root.author } // highlight-end const maybeAuthor = await db.post.findUnique(// ...这样做的好处是双重的:
- 查询更优化:每当请求
Post上的字段时,author 会随主查询一并取出,Prisma 不会为author字段再发一次额外的数据库查询; - 类型更干净:
root.author已经被 include 进来,判空后直接返回即可,无需再做第二次findUnique调用。
代价是:任何对Post的查询(哪怕客户端根本没请求author字段)都会多带一次关联查询。是否需要这样做,取决于你的业务中Post与author的访问频率比。
3.api/src/lib/auth中 CurrentUser 的角色检查(hasRole)
设置认证功能时,Redwood 会为api/src/lib/auth.ts生成包含hasRole函数、用于角色检查的模板代码。虽然 Redwood 在运行时会做防护(例如先调用isAuthenticated()再访问roles),但 TypeScript 在严格模式下依然会根据context.currentUser的实际类型报错——具体报不报错,取决于你的getCurrentUser是否返回roles字段,以及roles是string还是string[]。
export const hasRole = (roles: AllowedRoles): boolean => { if (!isAuthenticated()) { return false } // highlight-next-line const currentUserRoles = context.currentUser?.roles // Error: Property 'roles' does not exist on type '{ id: number; }'.ts(2339) }例如当getCurrentUser只返回{ id: number }时,context.currentUser?.roles就会触发TS2339(属性roles不存在)。你需要根据自己的 User 模型调整生成的代码,原文档给出了三种情况的处理建议。
A. 项目不使用角色
如果getCurrentUser不返回roles,并且你也不使用角色功能,可以直接删除hasRole函数(以及requireAuth中与roles相关的分支)。
B.currentUser.roles是单个字符串
如果角色的类型是string,则可以删除模板中针对数组的检查分支:
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.currentUser.roles是字符串数组
如果 User 模型中roles是string[]且永远不会是单个字符串,则可以删除大部分模板代码:
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 }源码佐证:模板的完整形态
通过yarn rw setup auth dbAuth安装 dbAuth 后,api/src/lib/auth.ts会以 packages/auth-providers/dbAuth/setup/src/templates/api/lib/auth.ts.template 为模板生成。从该模板可以看到完整的hasRole实现——它同时处理了string/string[]四种排列组合:
type AllowedRoles = string | string[] | undefined export const hasRole = (roles: AllowedRoles): boolean => { if (!isAuthenticated()) { return false } const currentUserRoles = context.currentUser?.roles if (typeof roles === 'string') { if (typeof currentUserRoles === 'string') { // roles to check is a string, currentUser.roles is a 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)) { // roles to check is an array, currentUser.roles is an array 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 }而在未设置认证的空白项目中,模板 packages/create-redwood-app/templates/ts/api/src/lib/auth.ts 的hasRole则是最简形态(roles !== undefined即为 true),这印证了原文档所说:模板代码因认证方案与 User 模型而异,严格模式下需要按实际情况收敛。角色检查的更完整业务用法可参考 how-to/role-based-access-control.md。
4.api/src/lib/auth.ts中的getCurrentUser类型标注
getCurrentUser的入参形状取决于你的认证提供方——除 dbAuth 外(dbAuth 的 session 固定是{ id }),其他提供方的 decoded token 形状会因账户设置(是否包含 roles、其他 metadata 等)而变化,Redwood 在 setup 时无法预知。因此严格模式下,你需要手动为getCurrentUser标注类型。
模板中getCurrentUser上方的注释已经描述了各参数的类型,帮助你起步。原文档特别提醒:不建议从 Redwood 导入过于泛化的类型来标注decoded(例如直接使用Decoded = Record<string, unknown> | null,见 packages/api/src/auth/parseJWT.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] ) => { // ... }源码级原理:AuthContextPayload的真实形状
AuthContextPayload定义在 packages/api/src/auth/index.ts 中,它是一个三元组(tuple),对应getCurrentUser的三个参数:
export type AuthContextPayload = [ Decoded, { type: string } & AuthorizationHeader, // @MARK: Context is not passed when using middleware auth { event: APIGatewayProxyEvent | Request context?: LambdaContext }, ]- 第 0 项:
Decoded,即解码后的 token(Record<string, unknown> | null); - 第 1 项:
{ type: string } & AuthorizationHeader,其中AuthorizationHeader为{ schema: 'Bearer' | 'Basic' | string; token: string },即认证类型、认证方案与原始 token; - 第 2 项:请求上下文,包含
event(Lambda 事件或 Fetch API 的Request)与可选的context(Lambda 上下文,middleware 认证模式下不传递)。
所以示例二中的AuthContextPayload[1]与AuthContextPayload[2]正是对后两个参数的精确索引类型。getCurrentUser从认证上下文解析而来的完整调用链可以在同一文件的getAuthenticationContext函数中看到:它负责解析 Cookie/Authorization 头、依次运行authDecoder直到拿到 decoded 结果,最终返回[decoded, { type, schema, token }, { event, context }]这一三元组。
四、总结与进一步阅读
开启严格模式不是一蹴而就的"开关",而是一次与生成代码的"对话"。本文覆盖了最常遇到的四类调整:
| 场景 | 报错本质 | 解决方案 |
|---|---|---|
| Services 的 mutation input | GraphQL 的nullvs Prisma 的undefined语义差异 | 使用@redwoodjs/api的removeNulls(源码见 transforms.ts) |
| 关系解析器 | findUnique永远可空,但 schema 声明必填 | 类型断言或显式判空;必要时include进主查询 |
hasRole角色检查 | context.currentUser上不存在roles属性(TS2339) | 按角色实际形态(无 /string/string[])裁剪模板代码 |
getCurrentUser | decoded token 形状未知 | 手动标注具体字段类型,或索引AuthContextPayload |
完成以上改造后再次运行yarn rw g types并让tsc(或编辑器的 TS Server)重新检查,红色的波浪线应该基本消失。若想继续深入,可参阅:
- typescript/introduction.md 与 typescript/generated-types.md:Redwood 类型系统的整体设计与生成类型机制;
- typescript/strict-mode.md:当前版本(main 分支)的严格模式文档,与本文内容一致;
- services.md 与 graphql.md:Services 与 GraphQL 层的完整实践;
- authentication.md:认证与
getCurrentUser/hasRole的完整背景。
- 后端
- 前端
- Web框架
- 开发工具
【免费下载链接】redwood
RedwoodGraphQL
相关推荐
Redwood TypeScript Strict Mode 实战指南:开启严格模式与适配生成代码
Redwood TypeScript Strict Mode 实战指南:开启严格模式与适配生成代码 RedwoodJS 框架默认不开启 TypeScript 的
后端前端Web框架开发工具Redwood TypeScript 严格模式(Strict Mode)完整指南:开启方式与生成代码的实战调整
Redwood TypeScript 严格模式(Strict Mode)完整指南:开启方式与生成代码的实战调整 RedwoodJS 默认不开启 TypeScri
后端前端Web框架开发工具StarRocks 严格模式(Strict Mode)完全指南:原理、配置与实战
StarRocks 严格模式(Strict Mode)完全指南:原理、配置与实战 Strict mode(严格模式)是 StarRocks 数据导入链路中用于控
数据库OLAP数据仓库大数据湖仓一体数据分析
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考