首页
/ Supabase 单体仓库中的 Vitest 类型测试实战:expectTypeOf 与 assertType 完全指南

Supabase 单体仓库中的 Vitest 类型测试实战:expectTypeOf 与 assertType 完全指南

2026-09-06 09:58:24作者:瞿蔚英Wynne

本篇基于 Supabase 仓库中的 Vitest 技能参考文档 advanced-type-testing.md,系统讲解如何在 TypeScript 项目中用 expectTypeOfassertType 做"零运行时"的类型级断言。Supabase 是一个以 Postgres 为核心的开发平台,其官网、文档站、Studio 控制台等前端资产均以 pnpm + Vitest 的 TypeScript 单体仓库形式组织;读完本文,你将掌握类型测试的文件约定(.test-d.ts)、test.typecheck 配置项、expectTypeOf 的完整断言 API(函数参数、对象形状、品牌类型、泛型、可空类型),以及如何把类型测试融入仓库现有的 turbo typecheck 流水线。

为什么需要类型测试

运行时单元测试验证的是"行为对不对",而类型测试验证的是"类型契约稳不稳"。expectTypeOfassertType 全部工作在类型层面,编译后即被擦除,不产生任何运行时开销。它们特别适合守护以下契约:

  • 公共工具函数的参数与返回值签名,防止签名被悄悄改宽或改窄;
  • 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.jsonpackages/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: trueenvironment: 'jsdom'setupFilescoverage),后文的 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()
})

注意两点:

  1. expectTypeOf 直接来自 vitest 包,无需额外依赖;
  2. 断言本身是"类型级"的,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 路径,保证检查时的编译选项(strictpaths 等)与项目一致

对照仓库实际配置:apps/studio/vitest.config.ts 目前只配置了 globalsenvironmentsetupFilescoverage 等运行期项,尚未启用 typecheck 块;这意味着 Studio 的类型保障目前主要来自各包 tsc --noEmit 的 typecheck 脚本(由根级 turbo --continue typecheck 聚合)。若在某个包中新增 .test-d.ts 文件,建议在对应包的 vitest.config.ts 中按上表补全 typecheck 块,同时确认 tsconfig 的 include 覆盖到该文件,这样 tsc --noEmitvitest 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 文件只要无法通过 tscvitest 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 检查的是类型完全相等AB 因字段不同而不相等,所以必须用 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>()

两条断言分别确认:UserIdPostId 不等价(交叉类型中的 __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() 覆盖 nullundefined 任意一方参与联合的情况。结合负向断言(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.jsonpackages/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 typecheckvitest --typecheckvitest --typecheck.only 三种方式运行,并与仓库既有的 turbo typecheck 流水线互补。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388