TypeScript 类型体操:一个实战案例

前阵子重构一个表单组件库,遇到个类型定义的问题。说实话一开始觉得用 any 算了,但想到以后维护的人会骂我,还是硬着头皮搞了。

场景

我们有个动态表单配置,大概长这样:

type FieldType = 'text' | 'number' | 'select' | 'date';

interface BaseField {
  name: string;
  label: string;
  required?: boolean;
}

interface TextField extends BaseField {
  type: 'text';
  maxLength?: number;
}

interface NumberField extends BaseField {
  type: 'number';
  min?: number;
  max?: number;
}

interface SelectField extends BaseField {
  type: 'select';
  options: { label: string; value: string }[];
}

问题来了,怎么定义一个 Field 类型,让它根据 type 字段自动推断出具体的字段类型?

尝试 1:联合类型

type Field = TextField | NumberField | SelectField;

function getOptions(field: Field) {
  if (field.type === 'select') {
    return field.options; // OK,TypeScript 能推断
  }
  return undefined;
}

这个能用,但是有个问题——当我们定义表单配置数组的时候:

const fields: Field[] = [
  { type: 'text', name: 'username', label: '用户名', maxLength: 20 },
  { type: 'select', name: 'role', label: '角色' } // 报错!缺少 options
];

类型太宽泛了,TypeScript 不知道 type: 'select' 必须有 options

尝试 2:泛型 + 映射

折腾了一会儿,想到用映射类型:

type FieldMap = {
  text: TextField;
  number: NumberField;
  select: SelectField;
  date: DateField;
};

type Field<T extends FieldType = FieldType> = T extends FieldType
  ? FieldMap[T]
  : never;

还是不太对,这样 Field 还是联合类型。

最终方案

type FieldConfig<T extends FieldType[]> = {
  [K in T[number]]: FieldMap[K];
}[T[number]];

// 使用
const fields = [
  { type: 'text', name: 'username', label: '用户名' },
  { type: 'select', name: 'role', label: '角色', options: [] },
] as const satisfies FieldConfig<('text' | 'select')[]>;

好吧,这个也有点绕。实际上最后项目里用的是另一种方案——函数生成器:

function defineField<T extends FieldType>(
  field: FieldMap[T]
): FieldMap[T] {
  return field;
}

// 这样写就有完整类型提示了
const fields = [
  defineField({ type: 'text', name: 'username', label: '用户名' }),
  defineField({ type: 'select', name: 'role', label: '角色', options: [] }),
];

简单粗暴,但有效。

后记

类型体操确实有用,但也别过度。能用简单方案解决的,就别为了炫技搞一堆 infer 和条件类型。代码是给人看的,能让 IDE 提示正确就够了。

另外推荐个网站叫 type-challenges,有空刷刷还挺有意思的,虽然工作中用不到那么复杂的。