TypeScript代码规范指南Skill typescript

这个技能提供了TypeScript代码开发的最佳实践指南,包括类型共位原则、命名规范(如首字母缩略词camelCase处理)、测试文件组织、常量数组命名约定、工厂函数参数解构、Arktype可选属性模式、品牌类型使用和const泛型数组推断。适用于前端和后端开发,帮助团队标准化代码风格,提高代码质量、可维护性和测试效率。关键词:TypeScript, 代码规范, 开发指南, 命名约定, 测试组织, 类型系统, 前端开发, 后端开发, 软件开发

前端开发 0 次安装 0 次浏览 更新于 3/20/2026

名称: 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;
	}
}

当情况用letconst声明变量时,使用块作用域({ })。

何时不使用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 = { ... };

共位规则

  1. 服务特定类型:放在[服务文件夹]/types.ts
  2. 组件特定类型:直接在组件文件中定义
  3. 共享域类型:放在域文件夹的types.ts
  4. 跨域类型:仅当真正在多个域间共享时,放在$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中的每个类型都可以用typeofz.inferInferTableRowReturnType等推导,则该文件是冗余的。将每个类型放在计算它的运行时值旁边。

常量数组命名约定

模式摘要

模式 后缀 描述 示例
简单值(真相源) 带单位的复数名词 原始值数组 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之外的额外字段时使用(例如,icondesktopOnly):

// 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 };
}

为何重要

  1. 更少行数:移除额外的解构语句
  2. 在API边界提供默认值:用户在签名中看到默认值,而不是隐藏在体内
  3. const泛型配合:TypeScript字面量推断正常工作:
    function select<const TOptions extends readonly string[]>({
      options,
      nullable = false,
    }: {
      options: TOptions;
      nullable?: boolean;
    }) { ... }
    
  4. 闭包同样工作:内部函数以相同方式捕获变量

何时体内解构有效

  • 需要区分“属性缺失”与“属性是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');
});

为何内联更好

  1. 所有上下文在一处:无需滚动即可理解poststheme是什么
  2. 减少命名开销:无需为单次使用值发明变量名
  3. 匹配心智模型:定义即是用法 - 它们是一个概念单元
  4. 更易于复制/修改:自包含的测试设置更容易复制和调整

何时提取

提取到变量当:

  • 值在同一个测试中多次使用
  • 您需要在结果上调用方法(例如,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]); // 良好:一致

为何品牌构造函数更好

  1. 单一真相源:只有一个地方有类型断言
  2. 未来验证:易于稍后添加运行时验证
  3. 可搜索RowId(易于查找和审计
  4. 明确边界:清晰显示无品牌 -> 品牌转换发生的地方
  5. 重构安全:在一个地方更改品牌逻辑
  6. 无阴影: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获取详情。