关于 typescript-best-practices
面向 FiveM 资源开发者的 TypeScript 指导技能,强调建模有效状态,并在运行时边界验证无类型数据,让业务逻辑依赖更可靠的类型。
主要功能
- 01用可辨识联合建模变体
- 02用品牌类型区分标识符
- 03在边界验证无类型数据
- 04优先使用 unknown 和可信类型守卫
- 05测试纯逻辑并记录有效上下文
适用场景
- 01为 FiveM 网络事件输入添加类型
- 02验证 NUI 和持久化数据
- 03建模互斥状态
- 04减少资源代码中的不安全类型断言
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ overextended/ skills --skill typescript-best-practices安装后在 Claude Code 中输入/typescript-best-practices确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
CREDITS.mdPATTERNS.mdagents/openai.yaml详细介绍
TypeScript 最佳实践
让编译器拒绝不可能的状态、混淆的 ID 和未处理的变体。在 FiveM 资源中,大部分类型安全性会在运行时的边界处流失:onNet、exports、状态包和 NUI 回调的类型都是 any 或 Function,因此那里的类型注解只是一个未经检查的强制转换。在这些边界处解析数据,然后在其他地方信任类型。
原则
- 让非法状态无法表示。 如果你需要写注释来说明某种字段组合何时有效,就把它拆分为判别联合类型。
- 构建类型,而不是限制类型。 选择无法容纳非法值的结构(头部加剩余部分、起始值加持续时间),而不是宽松类型加运行时检查。
- 不要欺骗编译器。 如果它无法证明某个事实,就去证明它(验证、收窄类型、重新建模)。追踪每个
any、as和!,直到边界处,并在那里进行验证。 - 仅在出现不完整性时强化类型。
!、强制转换或“绝不应该发生”的抛错,都表明类型过于宽松。将检查推到类型中,然后就此打住。 - 在边界处验证,在内部信任。 业务逻辑放在接收类型化数据的普通函数中。外围事件处理器应保持精简:解析、调用、回复。
规则
| 规则 | 摘要 |
|---|---|
| 判别联合类型 | 使用 kind 字面量判别字段对变体建模,使不可能的状态无法表示。不使用可选字段集合。 |
| 品牌类型 | 使用 & { readonly __brand: "X" } 为原始类型添加品牌,避免混淆服务端 ID、角色 ID 和物品名称。在边界处验证一次。 |
| 构造式建模 | 设计类型结构,使非法值无法构造。非空数组使用 [T, ...T[]],偶数长度数组使用 [T, T][],范围使用 start 加 duration。 |
| 最简单的全定义类型 | 只要对 T[] 的所有操作都保持全定义,就继续使用 T[]。只有当宽松类型迫使你使用 !、类型断言或“理论上不可能发生”的异常时,才加强为 NonEmpty<T>。 |
优先使用 unknown 而非 any | 来自资源外部的数据都是 unknown,包括网络事件处理程序的参数。 |
| 解析函数 | 使用接收 unknown 并返回值或 null 的函数,将客户端数据转换为类型化值(toCoords(value: unknown): Vec3 | null)。编写新解析器前,先复用资源现有的解析器和类型守卫。不要添加架构库:FiveM 资源不会使用这类库。 |
null 表示已省略 | FiveM 使用 MessagePack 序列化事件参数,可能会将省略的值或 undefined 转换为 null。使用 != null 检查可选值,绝不能只用 !== undefined;并根据类型和范围而非真值判断是否需要值:0 和 false 都是有效值。 |
不使用 as 类型断言 | 每个 as 都可能导致运行时崩溃。只能在验证后进行类型断言。类型参数(例如 triggerServerCallback<T>)也属于类型断言。 |
| 收窄优先级 | 判别字段 switch > in 运算符 > typeof/instanceof > 用户定义的类型守卫 > as。 |
| 类型守卫 | 必须验证所声称的内容。撒谎的类型守卫比 as 更糟,因为错误会藏在一个宣称安全的名称后面。命名为 isX 或 hasX。 |
| 穷尽性检查 | 在 default 分支中内联 const _exhaustive: never = x;,这样添加新变体时编译器会报错。 |
优先使用 satisfies 而非 as | 验证值,同时不拓宽字面量类型。 |
| 边界验证 | 在数据进入系统的边界处,将其解析为命名的领域类型。内部代码信任类型。参见下文的 FiveM 边界。 |
| 派生类型 | 声明新接口前,先考虑使用 Pick/Omit/Parameters/ReturnType/Awaited/typeof。客户端脚本和 NUI 共用一个类型,不要重复声明。 |
| 对象参数 | 传递对象而非位置参数,让参数顺序一目了然。热点路径(setTick 回调、任何逐帧运行的代码)除外;也不要为了使用对象参数而包装原生函数。 |
| 真实测试 | 能实际运行的内容就不要模拟。资源有测试运行器时,为纯逻辑编写真正的单元测试;原生函数、事件和 NUI 则在运行中的服务端上验证。 |
| 有用的日志 | 记录足够的上下文,以便通过 ID 进行调试:玩家 ID、事件名称、资源。发布的代码中不得留下调试用的 console.log。 |
FiveM 边界
以下每一项都是编译器一无所知的数据。数据到达时就解析:
| 边界 | 无类型的原因 |
|---|---|
服务器上的 onNet 处理程序参数 | 任何客户端都可以使用任意参数触发事件。onNet 接受 Function,因此参数注解不会经过检查。 |
onClientCallback 参数(@overextended/ox_lib/server) | 类型为 ...args: any[],且这些参数来自客户端。 |
RegisterNuiCallback 数据 | 数据来自浏览器,玩家可以通过开发者工具操控它。服务器会重新检查客户端转发的所有内容。 |
exports.<resource>.<name>() 结果 | 每个导出的类型都是 Function,因此其结果为 any。用类型化函数包装一次即可。 |
状态包(GlobalState、Player(id).state、Entity(id).state) | 索引类型为 any。将客户端可以写入的键视为客户端数据。 |
triggerServerCallback<T> 回复(@overextended/ox_lib/client) | T 不会经过检查,且返回类型为 Promise<T> | void:如果 delay 速率限制丢弃了调用,它就会解析为空。 |
JSON.parse、KVP 字符串、convar、数据库行、物品元数据 | 这些都是字符串或无类型的行。持久化的 JSON 应包含版本字段,并在解析时使用 try / catch。 |
在服务器上,每个处理程序的开头都要先将 source 复制到局部变量,再执行任何 await:它是一个全局变量,下一个事件会覆盖它。
如果资源使用 TypeScript 中的 ox_lib,请导入 @overextended/ox_lib,而不要直接调用 exports.ox_lib:该包会为导出内容添加类型。
示例
阅读 PATTERNS.md,查看每条规则对应的代码示例。
评论
支持 Markdown