名称: typescript 描述: TypeScript代码风格、类型共位、命名约定(包括首字母缩略词大小写)、测试组织和arktype模式。在编写TypeScript代码、定义类型、命名变量/函数、组织测试或处理arktype模式时使用。
TypeScript 指南
核心规则
-
在TypeScript中,总是使用
type而不是interface。 -
readonly仅用于数组和映射:切勿在原始属性或对象属性上使用readonly。该修饰符是浅层的,对非集合类型提供很少保护。仅在实际可能引发错误的地方使用:// 良好 - 仅在数组上使用readonly type Config = { version: number; vendor: string; items: readonly string[]; }; // 不佳 - 到处使用readonly是噪音 type Config = { readonly version: number; readonly vendor: string; readonly items: readonly string[]; };例外:精确匹配上游库类型(例如,标准模式接口)。参见
docs/articles/readonly-is-mostly-noise.md获取原理。 -
在camelCase中使用首字母缩略词:将首字母缩略词视为单个单词,仅首字母大写:
// 正确 - 首字母缩略词作为单词 parseUrl(); defineKv(); readJson(); customerId; httpClient; // 不正确 - 全大写首字母缩略词 parseURL(); defineKV(); readJSON(); customerID; HTTPClient;例外:匹配现有平台API(例如,
XMLHttpRequest)。参见docs/articles/acronyms-in-camelcase.md获取原理。 -
TypeScript 5.5+ 自动推断
.filter()回调中的类型谓词。不要添加手动类型断言:// 良好 - TypeScript自动推断缩窄类型 const filtered = items.filter((x) => x !== undefined); // 不佳 - 不必要的类型谓词 const filtered = items.filter( (x): x is NonNullable<typeof x> => x !== undefined, ); -
当将组件移动到新位置时,总是更新相对导入为绝对导入(例如,将
import Component from '../Component.svelte'改为import Component from '$lib/components/Component.svelte') -
当函数仅在工厂/创建者函数的返回语句中使用时,使用对象方法简写语法,而不是单独定义它们。例如,而不是:
function myFunction() { const helper = () => { /* ... */ }; return { helper }; }使用:
function myFunction() { return { helper() { /* ... */ }, }; }
用Switch替代If/Else进行值比较
当多个if/else if分支比较同一个变量与字符串字面量(或其他常量值)时,总是使用switch语句。这适用于操作类型、状态字段、文件类型、策略名称或任何判别值。
// 不佳 - if/else链比较相同变量
if (change.action === 'add') {
handleAdd(change);
} else if (change.action === 'update') {
handleUpdate(change);
} else if (change.action === 'delete') {
handleDelete(change);
}
// 良好 - switch语句
switch (change.action) {
case 'add':
handleAdd(change);
break;
case 'update':
handleUpdate(change);
break;
case 'delete':
handleDelete(change);
break;
}
使用fall-through处理共享逻辑的情况:
switch (change.action) {
case 'add':
case 'update': {
applyChange(change);
break;
}
case 'delete': {
removeChange(change);
break;
}
}
当情况用let或const声明变量时,使用块作用域({ })。
何时不使用switch:对于类型缩窄的早期返回,作为顺序if语句是可行的。如果每个分支立即返回,并且检查是为后续代码缩窄联合类型,则保持为if守卫。
参见docs/articles/switch-over-if-else-for-value-comparison.md获取原理。
类型共位原则
切勿使用通用类型桶
不要创建通用类型文件如$lib/types/models.ts。这会产生不清晰的依赖关系,并使代码更难维护。
不佳模式
// $lib/types/models.ts - 不相关类型的通用桶
export type LocalModelConfig = { ... };
export type UserModel = { ... };
export type SessionModel = { ... };
良好模式
// $lib/services/transcription/local/types.ts - 与服务共位
export type LocalModelConfig = { ... };
// $lib/services/user/types.ts - 与用户服务共位
export type UserModel = { ... };
共位规则
- 服务特定类型:放在
[服务文件夹]/types.ts中 - 组件特定类型:直接在组件文件中定义
- 共享域类型:放在域文件夹的
types.ts中 - 跨域类型:仅当真正在多个域间共享时,放在
$lib/types/[特定名称].ts中
types.ts 是代码异味(优先计算类型而非手动声明)
当类型可以从运行时值推导时,推导它。不要在单独文件中手动声明。
// 良好 — 类型从运行时定义计算
export const BROWSER_TABLES = { devices, tabs, windows };
export type Tab = InferTableRow<typeof BROWSER_TABLES.tabs>;
// 良好 — 类型从模式推导
const userSchema = z.object({ id: z.string(), email: z.string() });
type User = z.infer<typeof userSchema>;
// 不佳 — 手动声明已作为运行时值存在的内容
// types.ts
export type Tab = { id: string; deviceId: string /* ... */ };
如果types.ts中的每个类型都可以用typeof、z.infer、InferTableRow、ReturnType等推导,则该文件是冗余的。将每个类型放在计算它的运行时值旁边。
常量数组命名约定
模式摘要
| 模式 | 后缀 | 描述 | 示例 |
|---|---|---|---|
| 简单值(真相源) | 带单位的复数名词 | 原始值数组 | BITRATES_KBPS, SAMPLE_RATES |
| 丰富数组(真相源) | 复数名词 | 包含所有元数据 | PROVIDERS, RECORDING_MODE_OPTIONS |
| 仅ID(用于验证) | _IDS |
从丰富数组推导 | PROVIDER_IDS |
UI选项 {value, label} |
_OPTIONS |
用于下拉/选择 | BITRATE_OPTIONS, SAMPLE_RATE_OPTIONS |
| 标签映射 | _TO_LABEL(单数) |
Record<Id, string> |
LANGUAGES_TO_LABEL |
何时使用每个模式
模式1:简单值 -> 推导选项
当标签可以从值计算时使用:
// constants/audio/bitrate.ts
export const BITRATES_KBPS = ['16', '32', '64', '128'] as const;
export const BITRATE_OPTIONS = BITRATES_KBPS.map((bitrate) => ({
value: bitrate,
label: `${bitrate} kbps`,
}));
模式2:简单值 + 元数据对象
当标签需要比值本身更丰富的信息时使用:
// constants/audio/sample-rate.ts
export const SAMPLE_RATES = ['16000', '44100', '48000'] as const;
const SAMPLE_RATE_METADATA: Record<
SampleRate,
{ shortLabel: string; description: string }
> = {
'16000': { shortLabel: '16 kHz', description: '优化用于语音' },
'44100': { shortLabel: '44.1 kHz', description: 'CD质量' },
'48000': { shortLabel: '48 kHz', description: '工作室质量' },
};
export const SAMPLE_RATE_OPTIONS = SAMPLE_RATES.map((rate) => ({
value: rate,
label: `${SAMPLE_RATE_METADATA[rate].shortLabel} - ${SAMPLE_RATE_METADATA[rate].description}`,
}));
模式3:丰富数组作为真相源
当选项具有value/label之外的额外字段时使用(例如,icon、desktopOnly):
// constants/audio/recording-modes.ts
export const RECORDING_MODES = ['manual', 'vad', 'upload'] as const;
export type RecordingMode = (typeof RECORDING_MODES)[number];
export const RECORDING_MODE_OPTIONS = [
{ label: '手动', value: 'manual', icon: 'mic', desktopOnly: false },
{
label: '语音激活',
value: 'vad',
icon: 'mic-voice',
desktopOnly: false,
},
{ label: '上传文件', value: 'upload', icon: 'upload', desktopOnly: false },
] as const satisfies {
label: string;
value: RecordingMode;
icon: string;
desktopOnly: boolean;
}[];
// 如果需要,推导ID用于验证
export const RECORDING_MODE_IDS = RECORDING_MODE_OPTIONS.map((o) => o.value);
选择模式
| 场景 | 模式 |
|---|---|
| 标签 = 格式化值(例如,“128 kbps”) | 简单值 -> 推导 |
| 标签需要单独数据(例如,“16 kHz - 优化用于语音”) | 值 + 元数据 |
| 选项有额外UI字段(图标、描述、禁用) | 丰富数组 |
| 平台特定或运行时条件内容 | 保持在组件内联 |
命名规则
源数组
- 使用复数名词:
PROVIDERS,MODES,LANGUAGES - 当相关时添加单位后缀:
BITRATES_KBPS,SAMPLE_RATES - 避免冗余的
_VALUES后缀
推导/选项数组
- 使用复数名词 +
_OPTIONS后缀:BITRATE_OPTIONS,SAMPLE_RATE_OPTIONS - 对于ID:复数名词 +
_IDS后缀:PROVIDER_IDS
标签映射
- 使用单数
_TO_LABEL后缀:LANGUAGES_TO_LABEL - 描述操作(id -> 标签),而不是容器
- 自然读取:
LANGUAGES_TO_LABEL[lang]= “获取此语言的标签”
常量大小写
- 总是使用
SCREAMING_SNAKE_CASE用于导出的常量 - 切勿使用
camelCase用于常量对象/数组
共位
选项数组应与它们的源数组共位在同一文件中。避免在Svelte组件中内联创建选项;改为导入预定义选项。
例外:当选项具有平台特定或运行时条件内容,需要将平台常量导入数据模块时,保持选项内联。
工厂函数的参数解构
优先参数解构而非体内解构
当编写接受选项对象的工厂函数时,直接在函数签名中解构,而不是在函数体内解构。这是代码库中的既定模式。
不佳模式(体内解构)
// 不要:额外的仪式行
function createSomething(opts: { foo: string; bar?: number }) {
const { foo, bar = 10 } = opts; // 不必要的额外行
return { foo, bar };
}
良好模式(参数解构)
// 做:直接在参数中解构
function createSomething({ foo, bar = 10 }: { foo: string; bar?: number }) {
return { foo, bar };
}
为何重要
- 更少行数:移除额外的解构语句
- 在API边界提供默认值:用户在签名中看到默认值,而不是隐藏在体内
- 与
const泛型配合:TypeScript字面量推断正常工作:function select<const TOptions extends readonly string[]>({ options, nullable = false, }: { options: TOptions; nullable?: boolean; }) { ... } - 闭包同样工作:内部函数以相同方式捕获变量
何时体内解构有效
- 需要区分“属性缺失”与“属性是
undefined”('key' in opts) - 复杂的选项对象规范化/验证
- 需要将整个
opts对象传递给其他函数
代码库示例
// 从 packages/epicenter/src/core/schema/columns.ts
export function select<const TOptions extends readonly [string, ...string[]]>({
options,
nullable = false,
default: defaultValue,
}: {
options: TOptions;
nullable?: boolean;
default?: TOptions[number];
}): SelectColumnSchema<TOptions, boolean> {
return { type: 'select', nullable, options, default: defaultValue };
}
// 从 apps/whispering/.../create-key-recorder.svelte.ts
export function createKeyRecorder({
pressedKeys,
onRegister,
onClear,
}: {
pressedKeys: PressedKeys;
onRegister: (keyCombination: KeyboardEventSupportedKey[]) => void;
onClear: () => void;
}) { ... }
Arktype可选属性
切勿使用| undefined用于可选属性
在arktype模式中定义可选属性时,总是使用'key?'语法,而不是| undefined联合。这对于JSON Schema转换(用于OpenAPI/MCP)至关重要。
不佳模式
// 不要:显式的undefined联合 - 破坏JSON Schema转换
const schema = type({
window_id: 'string | undefined',
url: 'string | undefined',
});
这产生无效的JSON Schema,因为undefined没有JSON Schema等价物。
良好模式
// 做:可选属性语法 - 干净地转换为JSON Schema
const schema = type({
'window_id?': 'string',
'url?': 'string',
});
这在JSON Schema中正确省略了required数组中的属性。
为何重要
| 语法 | TypeScript行为 | JSON Schema |
|---|---|---|
key: 'string | undefined' |
必需属性,接受字符串或undefined | 破坏(触发回退) |
'key?': 'string' |
可选属性,接受字符串 | 干净(从required中省略) |
两者在TypeScript中行为相似,但只有?语法正确转换为JSON Schema,用于OpenAPI文档和MCP工具模式。
测试中的内联定义
优先内联单次使用定义
当模式、构建器或配置在测试中仅使用一次时,直接在调用站点内联它,而不是提取到变量。
不佳模式(提取变量)
test('creates workspace with tables', () => {
const posts = defineTable()
.version(type({ id: 'string', title: 'string' }))
.migrate((row) => row);
const theme = defineKv()
.version(type({ mode: "'light' | 'dark'" }))
.migrate((v) => v);
const workspace = defineWorkspace({
id: 'test-app',
tables: { posts },
kv: { theme },
});
expect(workspace.id).toBe('test-app');
});
良好模式(内联)
test('creates workspace with tables', () => {
const workspace = defineWorkspace({
id: 'test-app',
tables: {
posts: defineTable()
.version(type({ id: 'string', title: 'string' }))
.migrate((row) => row),
},
kv: {
theme: defineKv()
.version(type({ mode: "'light' | 'dark'" }))
.migrate((v) => v),
},
});
expect(workspace.id).toBe('test-app');
});
为何内联更好
- 所有上下文在一处:无需滚动即可理解
posts或theme是什么 - 减少命名开销:无需为单次使用值发明变量名
- 匹配心智模型:定义即是用法 - 它们是一个概念单元
- 更易于复制/修改:自包含的测试设置更容易复制和调整
何时提取
提取到变量当:
- 值在同一个测试中多次使用
- 您需要在结果上调用方法(例如,
posts.migrate(),posts.versions) - 定义在多个测试中共享,在
beforeEach或测试装置中 - 内联版本将超过约15-20行并损害可读性
适用于
defineTable(),defineKv(),defineWorkspace()构建器createTables(),createKV()工厂调用- 模式定义(arktype, zod等)
- 传递给工厂的配置对象
- 仅使用一次的模拟函数
测试文件组织
影子源文件与测试文件
每个源文件应在同一目录中有相应的测试文件:
src/static/
├── schema-union.ts
├── schema-union.test.ts # schema-union.ts的测试
├── define-table.ts
├── define-table.test.ts # define-table.ts的测试
├── create-tables.ts
├── create-tables.test.ts # create-tables.ts的测试
└── types.ts # 无测试文件(纯类型)
好处
- 清晰所有权:每个测试文件精确测试一个源文件
- 易于导航:通过查看源文件旁边找到测试
- 专注测试:更易于仅运行一个模块的测试
- 可维护性:当源更改时,您知道要更新哪个测试文件
什么获得测试文件
| 文件类型 | 测试文件? | 原因 |
|---|---|---|
| 有逻辑的函数/类 | 是 | 有行为要测试 |
| 仅类型定义 | 否 | 无运行时行为 |
重新导出桶(index.ts) |
否 | 仅重新导出,通过消费者测试 |
| 内部助手 | 可能 | 如果紧密耦合,通过消费者测试 |
命名约定
- 源:
foo-bar.ts - 测试:
foo-bar.test.ts
集成测试
对于跨越多个模块的测试,要么:
- 添加到最高级别消费者的测试文件中
- 如果大量,创建专用的
[feature].integration.test.ts
品牌类型模式
使用品牌构造函数,切勿使用原始类型断言
当处理品牌类型(名义类型)时,总是创建品牌构造函数函数。切勿在整个代码库中散布as BrandedType断言。
不佳模式(散布断言)
// types.ts
type RowId = string & Brand<'RowId'>;
// file1.ts
const id = someString as RowId; // 不佳:断言在这里
// file2.ts
function getRow(id: string) {
doSomething(id as RowId); // 不佳:另一个断言
}
// file3.ts
const parsed = key.split(':')[0] as RowId; // 不佳:断言到处
良好模式(品牌构造函数)
// types.ts
import type { Brand } from 'wellcrafted/brand';
type RowId = string & Brand<'RowId'>;
// 品牌构造函数 - 唯一的带有`as RowId`的地方
// 使用PascalCase以匹配类型名(避免参数阴影)
function RowId(id: string): RowId {
return id as RowId;
}
// file1.ts
const id = RowId(someString); // 良好:使用构造函数
// file2.ts
function getRow(rowId: string) {
doSomething(RowId(rowId)); // 良好:无阴影问题
}
// file3.ts
const parsed = RowId(key.split(':')[0]); // 良好:一致
为何品牌构造函数更好
- 单一真相源:只有一个地方有类型断言
- 未来验证:易于稍后添加运行时验证
- 可搜索:
RowId(易于查找和审计 - 明确边界:清晰显示无品牌 -> 品牌转换发生的地方
- 重构安全:在一个地方更改品牌逻辑
- 无阴影:PascalCase构造函数不阴影camelCase参数
实现模式
import type { Brand } from 'wellcrafted/brand';
// 1. 定义品牌类型
export type RowId = string & Brand<'RowId'>;
// 2. 创建品牌构造函数(代码库中唯一的`as`断言)
// PascalCase匹配类型 - TypeScript允许同名的类型和值
export function RowId(id: string): RowId {
return id as RowId;
}
// 3. 可选添加验证
export function RowId(id: string): RowId {
if (id.includes(':')) {
throw new Error(`RowId不能包含':':${id}`);
}
return id as RowId;
}
命名约定
| 品牌类型 | 构造函数函数 |
|---|---|
RowId |
RowId() |
FieldId |
FieldId() |
UserId |
UserId() |
DocumentGuid |
DocumentGuid() |
构造函数使用PascalCase匹配类型名。TypeScript允许类型和值共享相同名称(不同命名空间)。这避免了参数阴影问题。
当函数接受品牌类型时
如果函数需要品牌类型,调用者必须使用品牌构造函数:
// 函数需要品牌RowId
function getRow(id: RowId): Row { ... }
// 调用者必须品牌化字符串 - 无阴影,因为RowId()是PascalCase
function processRow(rowId: string) {
getRow(RowId(rowId)); // rowId参数不阴影RowId()函数
}
这使类型边界可见且有意,而不强制尴尬的参数重命名。
Const泛型数组推断
使用const T extends readonly T[]来保留字面类型,而不需要在调用站点使用as const。
| 模式 | 普通['a','b','c'] |
带as const |
|---|---|---|
T extends string[] |
string[] |
["a", "b", "c"] |
T extends readonly string[] |
string[] |
readonly ["a", "b", "c"] |
const T extends string[] |
["a", "b", "c"] |
["a", "b", "c"] |
const T extends readonly string[] |
readonly ["a", "b", "c"] |
readonly ["a", "b", "c"] |
const修饰符保留字面类型;readonly约束决定可变性。
// 从 packages/epicenter/src/core/schema/fields/factories.ts
export function select<const TOptions extends readonly [string, ...string[]]>({
id,
options,
}: {
id: string;
options: TOptions;
}): SelectField<TOptions> {
// ...
}
// 调用者获得字面联合类型 — 不需要`as const`
const status = select({ id: 'status', options: ['draft', 'published'] });
// status.options[number]是"draft" | "published",而不是string
参见docs/articles/typescript-const-modifier-generic-type-parameters.md获取详情。