Supabase 单体仓库中的 Vitest 类型测试实战:expectTypeOf 与 assertType 完全指南
本篇基于 Supabase 仓库中的 Vitest 技能参考文档 advanced-type-testing.md,系统讲解如何在 TypeScript 项目中用 expectTypeOf 与 assertType 做"零运行时"的类型级断言。Supabase 是一个以 Postgres 为核心的开发平台,其官网、文档站、Studio 控制台等前端资产均以 pnpm + Vitest 的 TypeScript 单体仓库形式组织;读完本文,你将掌握类型测试的文件约定(.test-d.ts)、test.typecheck 配置项、expectTypeOf 的完整断言 API(函数参数、对象形状、品牌类型、泛型、可空类型),以及如何把类型测试融入仓库现有的 turbo typecheck 流水线。
为什么需要类型测试
运行时单元测试验证的是"行为对不对",而类型测试验证的是"类型契约稳不稳"。expectTypeOf 和 assertType 全部工作在类型层面,编译后即被擦除,不产生任何运行时开销。它们特别适合守护以下契约:
- 公共工具函数的参数与返回值签名,防止签名被悄悄改宽或改窄;
- API 返回类型(如
User | null)的可空性约定; - 品牌类型(Branded Type)之间的互斥性,防止
UserId被误传给PostId参数。
仓库背景:Supabase 中的 Vitest 版本与测试基线
在动手之前,先看当前仓库的真实测试基线,便于理解本文所有示例的运行前提:
- 仓库通过 pnpm catalog 统一管理 Vitest 版本,
pnpm-workspace.yaml中声明vitest: ^4.1.4以及@vitest/coverage-v8: ^4.1.4、@vitest/ui: ^4.1.4,因此本文 API 基于 Vitest 4.x 语义;技能总览文档 SKILL.md 也标注该技能基于 Vitest 3.x 及以上生成,类型测试 API 在这一区间保持稳定。 - 根目录 package.json 提供
"typecheck": "turbo --continue typecheck",由各包(如 packages/pg-meta/package.json、packages/ui-patterns/package.json)的tsc --noEmit脚本组成整库类型检查流水线。test-d.ts文件同样会被 tsconfig 纳入,因此类型断言失败也会在turbo typecheck阶段暴露。 - 仓库中已经存在真实的
expectTypeOf用法:packages/pg-meta/test/pg-format.test.ts 首行即import { describe, expect, expectTypeOf, test } from 'vitest',并在第 306、311 行对 SQL 格式化的结果做expectTypeOf(result).toBeString断言——这是一个"运行时 + 类型断言混合测试"的现成案例,本文最后会展开。 - Studio 应用的 apps/studio/vitest.config.ts 展示了典型的
defineConfig写法(globals: true、environment: 'jsdom'、setupFiles、coverage),后文的typecheck配置块就挂在同一test键下。
准备工作:.test-d.ts 文件约定
类型测试文件统一使用 .test-d.ts 扩展名,以便与普通运行时测试区分。最简示例:
// math.test-d.ts
import { expectTypeOf } from 'vitest'
import { add } from './math'
test('add returns number', () => {
expectTypeOf(add).returns.toBeNumber()
})
注意两点:
expectTypeOf直接来自vitest包,无需额外依赖;- 断言本身是"类型级"的,
test回调在执行期只是空跑,真正的校验发生在 TypeScript 编译器对文件做类型检查时。
配置:test.typecheck
在 Vitest 配置中开启类型检查:
defineConfig({
test: {
typecheck: {
enabled: true,
// Only type check
only: false,
// Checker: 'tsc' or 'vue-tsc'
checker: 'tsc',
// Include patterns
include: ['**/*.test-d.ts'],
// tsconfig to use
tsconfig: './tsconfig.json',
},
},
})
各参数含义如下:
| 参数 | 类型 | 说明 |
|---|---|---|
enabled |
boolean |
开启类型测试模式;false 时 .test-d.ts 不会被执行类型检查流程 |
only |
boolean |
true 表示只跑类型检查,跳过普通运行时测试;对应 CLI 的 --typecheck.only |
checker |
'tsc' | 'vue-tsc' |
底层类型检查器;Vue 项目用 vue-tsc,本仓库这类 React/纯 TS 项目保持默认的 tsc 即可 |
include |
string[] |
参与类型检查的文件 glob,默认匹配 **/*.test-d.ts |
tsconfig |
string |
指定使用的 tsconfig 路径,保证检查时的编译选项(strict、paths 等)与项目一致 |
对照仓库实际配置:apps/studio/vitest.config.ts 目前只配置了 globals、environment、setupFiles、coverage 等运行期项,尚未启用 typecheck 块;这意味着 Studio 的类型保障目前主要来自各包 tsc --noEmit 的 typecheck 脚本(由根级 turbo --continue typecheck 聚合)。若在某个包中新增 .test-d.ts 文件,建议在对应包的 vitest.config.ts 中按上表补全 typecheck 块,同时确认 tsconfig 的 include 覆盖到该文件,这样 tsc --noEmit 与 vitest typecheck 两条通道都能生效。
expectTypeOf 基础断言:原始类型全集
expectTypeOf<T>() 的泛型参数即被测试的目标类型。对原始类型,API 提供了一组"精确匹配"的断言:
import { expectTypeOf } from 'vitest'
// Basic type checks
expectTypeOf<string>().toBeString()
expectTypeOf<number>().toBeNumber()
expectTypeOf<boolean>().toBeBoolean()
expectTypeOf<null>().toBeNull()
expectTypeOf<undefined>().toBeUndefined()
expectTypeOf<void>().toBeVoid()
expectTypeOf<never>().toBeNever()
expectTypeOf<any>().toBeAny()
expectTypeOf<unknown>().toBeUnknown()
expectTypeOf<object>().toBeObject()
expectTypeOf<Function>().toBeFunction()
expectTypeOf<[]>().toBeArray()
expectTypeOf<symbol>().toBeSymbol()
这些断言的失败表现是"类型错误"而非运行时失败:当 expectTypeOf<number>().toBeString() 不成立时,toBeString() 的调用本身会产生编译错误,因此 .test-d.ts 文件只要无法通过 tsc,vitest typecheck 就会报红。
值层面的类型检查
除了直接用泛型参数声明类型,expectTypeOf 也可以以实际值作为输入,编译器会推断出该值的类型再做断言:
const value = 'hello'
expectTypeOf(value).toBeString()
const obj = { name: 'test', count: 42 }
expectTypeOf(obj).toMatchTypeOf<{ name: string }>()
expectTypeOf(obj).toHaveProperty('name')
这里体现了两个不同力度的断言:
toMatchTypeOf是"结构子集匹配":实际类型只需兼容目标类型(可多字段);toHaveProperty只校验"该属性存在",不约束其类型。
函数类型断言
对函数,expectTypeOf(fn) 暴露 parameters(参数元组)与 returns(返回类型)两个维度:
function greet(name: string): string {
return `Hello, ${name}`
}
expectTypeOf(greet).toBeFunction()
expectTypeOf(greet).parameters.toEqualTypeOf<[string]>()
expectTypeOf(greet).returns.toBeString()
// Parameter checking
expectTypeOf(greet).parameter(0).toBeString()
parameters.toEqualTypeOf<[string]>()断言参数是一个"恰好只有一个 string 参数"的元组,可防止参数被新增、改宽或变成可选;returns.toBeString()断言返回类型;parameter(0)按位置取单个参数断言,适合只关心某一参数字段类型的场景。
对象类型断言
interface User {
id: number
name: string
email?: string
}
expectTypeOf<User>().toHaveProperty('id')
expectTypeOf<User>().toHaveProperty('name').toBeString()
// Check shape
expectTypeOf({ id: 1, name: 'test' }).toMatchTypeOf<User>()
toHaveProperty('name').toBeString() 展示了断言链:先定位属性,再对属性类型断言。toMatchTypeOf<User>() 用来校验一个具体字面量对象是否"长得像" User——因为 email 是可选的,{ id: 1, name: 'test' } 可以匹配 User,这正是子集匹配语义的典型应用。
相等性 vs 匹配:toEqualTypeOf 与 toMatchTypeOf 的语义边界
这是类型测试中最重要的区分:
interface A { x: number }
interface B { x: number; y: string }
// toMatchTypeOf - subset matching
expectTypeOf<B>().toMatchTypeOf<A>() // B extends A
// toEqualTypeOf - exact match
expectTypeOf<A>().not.toEqualTypeOf<B>() // Not exact match
expectTypeOf<A>().toEqualTypeOf<{ x: number }>() // Exact match
toMatchTypeOf检查的是可赋值性/子集关系(B extends A),允许目标类型缺少字段;toEqualTypeOf检查的是类型完全相等,A与B因字段不同而不相等,所以必须用not前缀;与结构化字面量{ x: number }则相等。
not 前缀可用于所有断言,表达"类型应当不等于/不属于……"的负向契约。
品牌类型(Branded Types)的互斥性验证
品牌类型是"同名底层、不同语义"类型的标准做法,类型测试是验证品牌之间确实互不兼容的最直接手段:
type UserId = number & { __brand: 'UserId' }
type PostId = number & { __brand: 'PostId' }
expectTypeOf<UserId>().not.toEqualTypeOf<PostId>()
expectTypeOf<UserId>().not.toEqualTypeOf<number>()
两条断言分别确认:UserId 与 PostId 不等价(交叉类型中的 __brand 标记生效),且品牌类型不等于裸 number(品牌真正增加了类型约束)。这类断言能防止有人日后把 UserId 简化回 number 别名时悄无声息地通过编译。
泛型函数的实例化断言
function identity<T>(value: T): T {
return value
}
expectTypeOf(identity<string>).returns.toBeString()
expectTypeOf(identity<number>).returns.toBeNumber()
对泛型函数可以直接用类型实参实例化(identity<string>)后再走 returns 链,用来锁定"具体化后的签名"。这对像 identity 这样的透传函数、以及 fetch<T> 一类返回 Promise<T> 的 API 包装尤其有用。
可空类型断言
type MaybeString = string | null | undefined
expectTypeOf<MaybeString>().toBeNullable()
expectTypeOf<string>().not.toBeNullable()
toBeNullable() 覆盖 null 或 undefined 任意一方参与联合的情况。结合负向断言(not.toBeNullable()),可以双向锁定一个返回类型"到底可不可空"——对 getUser(): User | null 这类 API 边界契约非常关键。
assertType:对值的类型级断言
assertType<T>(value) 不产生运行时断言,仅声明"该值必须可赋值给 T":
import { assertType } from 'vitest'
function getUser(): User | null {
return { id: 1, name: 'test' }
}
test('returns user', () => {
const result = getUser()
// @ts-expect-error - should fail type check
assertType<string>(result)
// Correct type
assertType<User | null>(result)
})
assertType<string>(result) 因 User | null 不可赋值给 string 而编译失败,前面的 @ts-expect-error 则把这个失败"转正"为一条测试预期——即"此处必须报错"。
用 @ts-expect-error 测试"代码必须产生类型错误"
test('rejects wrong types', () => {
function requireString(s: string) {}
// @ts-expect-error - number not assignable to string
requireString(123)
})
@ts-expect-error 的语义是"下一行必须报类型错误":如果错误出现,指令被消费,测试通过;如果错误消失(例如参数类型被放宽为 string | number),该指令本身变成"未使用的 expect-error"并导致编译失败。这是把类型收紧当作回归测试的经典技巧。
运行类型测试的三种方式
# Run type tests
vitest typecheck
# Run alongside unit tests
vitest --typecheck
# Type tests only
vitest --typecheck.only
vitest typecheck:专门进入类型检查子命令,等价于开启test.typecheck.enabled的独立运行;vitest --typecheck:类型测试与普通测试同批运行,适合希望一次拿到全部结果的 CI 场景;vitest --typecheck.only:只跑类型测试,跳过运行时用例。
落到本仓库的工程组织上:各包的 package.json 已存在 typecheck: tsc --noEmit 脚本(如 packages/common/package.json、packages/ui-patterns/package.json),根级通过 turbo --continue typecheck 全量聚合(见 package.json)。因此一个务实的落地组合是:本地用 vitest typecheck 快速迭代单个包的类型断言,CI 继续由 turbo typecheck 兜底,确保 .test-d.ts 文件在两条通道下都被检查。
混合测试文件:运行时断言与类型断言共存
类型测试不必孤立在 .test-d.ts 中,可以直接和普通运行时测试写在同一文件:
// user.test.ts
import { describe, expect, expectTypeOf, test } from 'vitest'
import { createUser } from './user'
describe('createUser', () => {
test('runtime: creates user', () => {
const user = createUser('John')
expect(user.name).toBe('John')
})
test('types: returns User type', () => {
expectTypeOf(createUser).returns.toMatchTypeOf<{ name: string }>()
})
})
Supabase 仓库中就有这一模式的真实用例:packages/pg-meta/test/pg-format.test.ts 同时使用 expect(运行时值断言)与 expectTypeOf(类型断言)来测试 SQL 格式化功能——在验证格式化输出内容的同时,用类型断言锁定"返回值必须是 string"这一契约。从源码结构看,这种混合写法在该 monorepo 中是可复制的既有实践:只要项目 devDependencies 里通过 pnpm catalog 引入的 vitest 版本支持 expectTypeOf(当前为 ^4.1.4),任何包的 .test.ts(x) 文件都可以直接导入使用。
关键要点回顾
- 类型专用测试文件使用
.test-d.ts扩展名; expectTypeOf是类型断言的主入口,支持原始类型、函数参数/返回值、对象属性、泛型实例化;toMatchTypeOf表示子集匹配(可赋值),toEqualTypeOf表示完全相等,二者语义不可混用;assertType用于"值必须可赋值给 T"的轻量声明;@ts-expect-error用于把"必须产生类型错误"固化为回归测试;- 通过
vitest typecheck、vitest --typecheck、vitest --typecheck.only三种方式运行,并与仓库既有的turbo typecheck流水线互补。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00