Metabase 前端 TypeScript 编码规范实战:从 no-any 硬性红线、类型建模到可验证的交付闭环
【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase
TypeScript/JavaScript 是 Metabase 前端的核心语言,仓库中沉淀了一套以「类型安全优先」为基调的编码规范,并以.claude/skills/typescript-write/SKILL.md技能文档的形式固化下来,供编码 Agent 与人工开发者共同遵守。本文围绕这份技能文档逐条拆解其背后的规则、配套命令与仓库内真实落地证据(含 ESLint 规则实现、类型定义与 package.json 脚本),帮助你写出符合 Metabase 标准、可通过自动化校验的 TS/TSX 代码。
技能文档的定位:Agent 与人类共用的编码准则
.claude/skills/typescript-write/SKILL.md是仓库内 Claude Skills 目录中的一个技能定义,其 YAML frontmatter 声明了用途:
name: typescript-writedescription: Write TypeScript and JavaScript code following Metabase coding standards and best practices. Use when developing or refactoring TypeScript/JavaScript code.
也就是说,该文档负责约束「在 Metabase 代码库中编写/重构 TS/JS」这一行为本身,目标对象既包括调用技能的编码 Agent,也包括遵循同一套准则的开发人员。它通过@指令组合引用了三个共享知识文件(均位于 .claude/skills/_shared/ 目录):
- development-workflow.md——自主开发工作流:不读写项目目录之外的文件、先写失败测试再修复、小步自治迭代、持续跑针对性测试与 lint、先理解既有模式再动手、不代提交 commit 交由用户审查、最小化注释。
- typescript-commands.md——lint / format / type-check / 测试的可用命令速查。
- react-redux-patterns.md——RTK Query、Redux、Hooks、加载与错误态等数据层与组件层模式。
在技能目录中它并非孤立存在:.claude/skills/下还有typescript-review(评审视角)、clojure-write、docs-write、e2e-test等配套技能,共同构成「写 → 查 → 测 → 文档」的开发闭环。本文聚焦 write 侧的 TypeScript 规范本身。
命令基线:写代码时随时可跑的工具链
技能要求开发过程中持续使用 lint 与类型检查,命令定义在 .claude/skills/_shared/typescript-commands.md,真实脚本位于仓库根目录 package.json:
| 阶段 | 命令 | 实际定义(package.json) |
|---|---|---|
| Lint | bun run lint-eslint-pure | eslint --cache --cache-strategy content --max-warnings 0 --report-unused-disable-directives enterprise/frontend frontend e2e(第 476 行) |
| 格式检查 | bun run lint-format-pure | oxfmt --check '{frontend,enterprise/frontend,e2e}/**/*.{js,jsx,ts,tsx,css}'(第 479 行) |
| 格式化 | bun run format | oxfmt --write '{frontend,enterprise/frontend,e2e}/**/*.{js,jsx,ts,tsx,css}'(第 485 行) |
| 类型检查 | bun run type-check-pure | ./node_modules/typescript7/bin/tsc --noEmit(第 508 行) |
| 单测(单文件) | bun run test-unit-keep-cljs path/to/file.unit.spec.js | jest --maxWorkers=4(第 498 行) |
| 单测(按 pattern) | bun run test-unit-keep-cljs -t "pattern" | 同上,由 Jest 的-t过滤用例 |
| ClojureScript 测试 | bun run test-cljs | bun install && shadow-cljs compile test && node target/node-tests.js(第 491 行) |
几个值得注意的实现细节:
- 统一使用 Bun 而非 npm/yarn:
preinstall脚本(第 483 行)会检查npm_execpath中是否包含bun,否则直接报错退出。因此文档中所有脚本都写成bun run ...。 type-check-pure调用的tsc来自名为typescript7的独立依赖(./node_modules/typescript7/bin/tsc --noEmit),即仓库内置了定制/分叉版本的编译器;完整的type-check(第 507 行)则先clean:cljs && build:cljs再做同样的type-check-pure。技能里「完成 TS/TSX 修改前必须跑bun run type-check-pure」即针对这种无需先构建 CLJS的纯前端校验路径。lint-eslint-pure带--max-warnings 0 --report-unused-disable-directives,意味着警告即失败、无效的 eslint-disable 也会报错,属于零容忍配置。
Noany:不可妥协的硬性红线
技能开篇即把「禁止any」定义为 hard rule,并给出了三种形态的禁止范围:
- 不允许显式或隐式
any:包括any注解、as any/as unknown as、被推断为any的无类型参数/返回值、隐式any的解构与数组/对象字面量。 - 未类型化的第三方/边界值必须在边界处定型:用声明的类型、
unknown+ 类型守卫(type guard),或一个小的类型化包装;绝不允许any向业务代码内部渗透。 - 每次 TS/TSX 变更完成前必须执行强制类型校验(前述
type-check-pure);若环境可用 TypeScript LSP,还需对变更符号做 hover 与 go-to-definition 检查。
这条红线的存在意义在于:Metabase 前端体量巨大,any一旦穿透边界就会切断整个类型图的连通性。因此在 review 场景(见配套的 typescript-review/SKILL.md)中,「是否引入新的any」通常是第一检查项。
类型收紧:能修签名,就别写强转
「Type tightening」章节的核心哲学是:出现类型问题时,优先修正函数签名,而不是用断言绕过去。逐条展开如下:
- 避免类型断言与松散的
unknown——修复签名本身。很多时候断言是「签名错了」的信号。 - 函数只用到宽对象里的一个字段,就只接收那个字段。把入参从
WholeObject收窄为WholeObject["field"]后,调用处的 cast 常常自然消失。 - 在写 cast 之前先考虑
Partial<T>、Pick<T, K>、Record<K, V>与泛型。它们用类型系统表达意图,而非用断言压制类型系统。 - 值原样流经组件且调用方已知类型时,优先把 props/组件做成泛型(
<T>),由调用方提供精确类型。 - 宁用
unknown也不要用松散类型,在使用点收窄——unknown强制你写出守卫。 - 对象字面量用
satisfies:当配置对象、查找表、可辨识字面量需要在「不拓宽类型」的前提下满足某个类型时,satisfies优于: T(会拓宽)也优于as T(不安全)。 - 避免非空断言
!:优先用守卫、提前 return 或?.;只有在「非空性可证明成立且作用域局部化」时才允许!,且必须配注释。 - 不做冗余运行时强转:已类型化的值不要再包
Number()/String()/Boolean()。 - 类型守卫统一放在
frontend/src/metabase-types/guards/,不允许在局部重复定义——这是「复用优于复制」在类型层上的体现。
关于最后一点,仓库证据非常清晰:frontend/src/metabase-types/guards/ 目录集中存放守卫,例如 card.ts 中的isSavedCard(card is Card)、dashboard.ts 中的isVirtualCard、parameters.ts 中的isDimensionTarget等,均为标准 TS 自定义类型守卫(x is T谓词形式)。
无法避免的 cast:必须写真实的理由注释
- 绕不开的 cast 需要一条真实理由注释。仓库用自定义 ESLint 规则强制这一点:
- 规则注册于 frontend/lint/eslint-plugin-metabase/index.js;
- 完整实现在 frontend/lint/eslint-plugin-metabase/rules/no-unjustified-type-casts.js,对
TSAsExpression(expr as T)与TSTypeAssertion(<T>expr)两类节点做检查,任何位于 cast 前的注释即可使其通过;同时豁免as const断言与嵌套在最外层 cast 内部的 cast。
该规则同时明确:永远不要写// Unjustified type cast. FIXME这类遗留占位注释——它只存在于规则上线前就有的历史 cast 上,照抄它等于让一个无理由的 cast 骗过 linter。如果你说不清 cast 为什么安全,那说明这个 cast 是错的,正确做法是修类型。从规则源码看(第 30-41 行),isConstAssertion与isOutermostCast两个分支会提前放行,其余一律要求「注释在紧邻 cast 的前一行/同行(含被 oxfmt 抬升到三元操作符?/:行尾的注释)」才通过校验。
类型建模:复用领域类型、让数据契约保持窄而精确
「Type modeling」章节解决的是「新类型从哪里来、边界怎么画」:
- 复用既有类型,不重复声明。使用
metabase-types/api提供的规范化 ID 与领域实体类型,并以它们为键构造数据结构(如new Map<ConcreteTableId, …>())。不要在业务文件里复制生成的/API 类型,应通过组合或派生获得:Pick、Omit、索引访问SomeType["field"]、ReturnType。仓库中的实际定义可印证这套命名体系,例如 database.ts 的DatabaseId = number、field.ts 的FieldId = number、table.ts 的ConcreteTableId(物理表)与VirtualTableId(如"card__17"这类虚拟表 ID)合成TableId,以及SchemaId/SchemaName等。 - 善用泛型让 TS 自动推断正确类型:对需要「可复用且类型安全」的函数与组件,不要畏惧引入较复杂的泛型,只要它们能取代手工收窄。
- 按真实数据契约建模,保持类型窄:键可能缺席用
field?: T;键恒存在但值可能是undefined用field: T | undefined;API 显式返回 null 才用| null。领域联合类型优先于宽泛的string/number/松散Record。 - 定义/修正类型时要参照 API 实现:先到 Clojure 侧找到对应 endpoint 实现,确认字段的真实形状与可空性,而不是凭前端臆测。这呼应了本文后面「null 与 undefined」中「对照 API 实现核对可空性」的原则。
- 可变状态用可辨识联合(discriminated union)+ 穷尽检查。把「N 种形态之一」建模成带字面量判别字段的联合类型,而不是一堆可选字段的大杂烩,并用 ts-pattern 的
.exhaustive()穷尽,使「新增一种形态」变成编译错误。技能给出了完整示例:
import { match } from "ts-pattern"; const result = match(status) .with({ type: "loading" }, () => <Spinner />) .with({ type: "error", error: P.select() }, (error) => <Error message={error.message} />) .with({ type: "success", data: P.select() }, (data) => <Content data={data} />) .exhaustive(); // Compile-time guarantee all cases handled仓库确实把 ts-pattern 作为一等依赖:它在 package.json 中被声明为"ts-pattern": "^5.9.0"。
- 从常量推导联合类型:用
as const+typeof/keyof让「类型」与「值」不可能漂移。 readonly/ 不可变性:对无意变更的输入(组件 props、共享常量、导出配置)优先使用readonly T[]/ReadonlyArray<T>。- 显式建模异步与错误状态:loading / error / empty 不允许「隐式存在」,要用可辨识联合或数据层自带的类型化结果表达。
Null 与 Undefined:源头收窄,消费点兜底
关于空值处理的四条准则强调「在哪一层解决」:
- 源头收窄:如果某值只在极端角落才可选,不要在每一层都把它当
undefined传递——在生产数据的源头(producer)加守卫。 - 给可选值合理默认:消费点用
?.与??兜底。 - 列表先过滤再使用:不要带着可能为 null/undefined 的成员进入
map或其他迭代。 - 避免非严格空比较:
X != null只有在X确实可能为null时才有意义;否则用严格比较或直接收窄类型,必要时使用checkNotNull这类工具。 - 对照 API 实现核对可空性:去读对应 API endpoint 的 Clojure 实现,确认字段到底能不能为 null——而不是想当然地到处加
?.。
这与「类型建模」中对?/| undefined/| null的三种区分是同一套世界观的两个面:一端管「字段是否存在」,另一端管「值是否为空」,都需要以后端数据契约为准。
命名:描述实体,而非机制或历史
- 名字描述值承载的实体,而不是实现机制。
- 对齐同类概念:同一组相关 API 之间保持动词习惯一致(例如增删改查的命名模式)。
- 不要用编码实现历史命名:
Base、New、Old、Initial这类后缀必须存在真实的语义差异,否则删掉。 - 避免晦涩标识符:领域值不要叫
v、n、$n;短名只允许出现在公认的极小上下文里(循环下标i、坐标x/y、泛型参数T/K/V)。
代码结构与组织:复用优先,函数小而专
- 复用优于复制:仓库已有大量工具函数,优先使用;若发现自己重复实现了相同逻辑,就抽取为共享工具。
- 通用 helper 不放功能目录:泛用工具要上提到共享层,避免在 feature 目录里私藏可复用逻辑。
- 函数保持短小、单一职责:超过百行的函数评审成本高,应拆成多个聚焦的具名 helper,各司其职、依赖面最小;必要时补单测。
- 把复杂 JSX 抽成具名组件:依据复用度、耦合度、可测性与可读性,决定抽取到同文件还是独立文件。
这部分与共享文件 react-redux-patterns.md 中的组件组织原则(展示组件配 Storybook stories、容器组件向下传窄 props、深层 state 尽量在高层读取等)互相呼应,共同定义了 Metabase 前端的代码形态。
注释:默认不写,写就写 why
- 默认不写注释:命名良好的标识符已经承担了
what(代码做什么)的表达。 - 注释必须精简,且只解释
why:workaround、隐藏不变量、微妙的顺序约束、巧妙的归约才值得注释。永远不要复述实现过程,注释聚焦意图与原因。
这与 development-workflow.md 中的要求一致:「只记录代码本身无法传达的非显然意图、权衡或约束,不叙述代码做了什么,让代码靠命名与结构自解释」。
交付前校验:把规范变成可执行的检查
技能的收尾章节「Verify before done」只有一句但很关键:完成后运行项目类型检查(即共享命令里的bun run type-check-pure)。结合整个技能体系,一次完整的 TS 变更交付流程可以归纳为:
- 先补失败测试(development-workflow:Add failing tests first, then fix them),用
bun run test-unit-keep-cljs <file>或-t "<pattern>"跑针对性用例; - 开发中持续跑
bun run lint-eslint-pure与bun run type-check-pure,让no-any、no-unjustified-type-casts等规则尽早暴露问题; - 结束时再做
bun run lint-format-pure/bun run format统一格式; - 不代提交 commit,把改动留给用户审查(development-workflow 明确要求)。
总结:一套「可被自动化强制」的类型文化
纵观 SKILL.md 全文,它的核心并不只是罗列「风格偏好」,而是把类型安全诉求转译成了可被工具强制执行的检查项:no-any靠心智与 code review 把关,no-unjustified-type-casts靠仓库自带 ESLint 规则兜底,.exhaustive()靠编译器穷尽,type-check-pure靠tsc做最终闸门。对于在 Metabase 前端(frontend/与enterprise/frontend/)写 TS/TSX 的开发者与 Agent 而言,这套规范的实际效果是:类型图保持连通、边界可审计、重构可放心进行。若需要从评审侧进一步验证这些约定是否被遵守,可直接参考同目录的 typescript-review/SKILL.md 与仓库自定义 lint 规则集 frontend/lint/eslint-plugin-metabase/。
【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考