我正在尝试创建一个反应组件,该组件根据配置对象呈现输入表,该对象定义字段属性名称及其输入类型以及初始数据行。
这是预期用途的示例:
const render = <C extends Config>(config: C, initialData: Row<C>[]) => {
return initialData
}
const config = {
properties: [
{
input: InputType.Text,
label: 'Crop',
name: 'crop',
},
{
input: InputType.Number,
label: 'Yield(t)',
name: 'yield_t',
},
],
}
const initialData = [
{ crop: 'corn', yield_t: 2 },
{ crop: 'barley', yield_t: 2 },
{ crop: 2, yield_t: 'ten' }, // ❌ bad input
]
render(config, initialData)
我正在尝试获取像上面这样的实例,但输入错误,以使 Typescript 抱怨。
这是我到目前为止所拥有的(也在 TS 游乐场)
/**
* WIP:
*
* An attempt to create typescript-level type validation of
* initialData dependent on the properties defined in config.
*/
enum InputType {
Text = 'Text',
Number = 'Number',
Date = 'Date',
}
export type FieldConfig = {
input: InputType
label: string
name: string
}
export type Config = {
properties: ReadonlyArray<FieldConfig>
}
export type Row<C extends Config> = {
[K in Names<C>]: GetFieldType<Extract<Properties<C>, { name: K }>['input']>
}
/* Extracts a union of properties from a config type,
* i.e. { name: 'crop', ... } | { name: 'yield_t', ... } */
type Properties<C extends Config> = C['properties'][number]
/* Gets the names of of the properties from a config type,
* i.e. 'crop' | 'yield_t' */
type Names<C extends Config> = C['properties'][number]['name']
/**
* Gets the scalar type of an Input Type
*/
type GetFieldType<Type extends keyof typeof InputType> =
Type extends InputType.Text
? string
: Type extends InputType.Date
? string
: Type extends InputType.Number
? number
: never
/**
* Converts an union of Properties into a mapped object type, i.e.:
*
* properties: [{ name: 'crop', ... }, { name: 'yield_t', ... }]
* ->
* {
* 'crop': { name: 'crop', ... },
* 'yield_t': { name: 'yield_t', ... },
* }
*/
type NameMap<C extends Config> = {
[PropertyName in Names<C>]: Extract<Properties<C>, { name: PropertyName }>
}
const render = <C extends Config>(config: C, initialData: Row<C>[]) => {
return initialData
}
const config = {
properties: [
{
input: InputType.Text,
label: 'Crop',
name: 'crop',
},
{
input: InputType.Number,
label: 'Yield(t)',
name: 'yield_t',
},
],
}
const initialData: Row<typeof config>[] = [
{ crop: 'corn', yield_t: 2 },
{ crop: 'barley', yield_t: 2 },
// @ts-expect-error this should fail :(
{ crop: 2, yield_t: 'ten' },
]
render(
config,
// @ts-expect-error this should fail :(
initialData,
)
最后,我的
Row
类型具有通用字符串键,其属性是可能输入值的并集:
理想情况下,推断的定义如下所示:
type MyRow = {
crop: string;
yield_t: number
}
接下来我将只处理
FieldConfig
而不是 Config
。我们不再采用 generic 参数 C extends Config
,然后像 C["properties"][number]
那样 索引到来获得
FieldConfig
的 union,我们只需从
F extends FieldConfig
作为所需的并集开始。
我采取的方法是定义一个映射接口
interface FieldTypeMap {
[InputType.Text]: string;
[InputType.Number]: number;
[InputType.Date]: string
}
它可以让您通过
索引访问查找给定
InputType
的字段类型。这与 T extends InputType.Text ? string : ⋯
形式的通用条件类型相反。对于 TypeScript 来说,通用索引访问比通用条件类型更容易处理。
现在我们可以将
Row<F>
定义为:
type Row<F extends FieldConfig> =
{ [T in F as T["name"]]: FieldTypeMap[T["input"]] }
它使用 key remapping 迭代
T
的每个联合成员 F
并使用 name
和 input
属性分别确定键和值。这比尝试使用 Extract
查找 F
的正确联合成员更简单。
让我们用您的示例来测试这部分:
const config = {
properties: [
{
input: InputType.Text,
label: 'Crop',
name: 'crop',
},
{
input: InputType.Number,
label: 'Yield(t)',
name: 'yield_t',
},
],
} as const;
type MyRow = Row<typeof config["properties"][number]>;
/* type MyRow = {
crop: string;
yield_t: number;
} */
请注意,我在 const
定义上使用了
config
断言,要求类型检查器跟踪所涉及字符串属性的文字类型,尤其是
name
属性。否则它只会被视为 string
并且没有希望将它们用作密钥。不管怎样,MyRow
类型看起来不错。
现在我们可以用
render()
来定义 Row<F>
:
const render = <const F extends FieldConfig>(
config: { properties: readonly F[] },
initialData: readonly Row<F>[]
) => {
return initialData
}
我还在 const
类型参数上添加了
F
修饰符,以向类型检查器提供提示:如果您为
config
输入对象文字参数,它应该再次跟踪属性的文字类型.
让我们尝试一下:
render({
properties: [
{
input: InputType.Text,
label: 'Crop',
name: 'crop',
},
{
input: InputType.Number,
label: 'Yield(t)',
name: 'yield_t',
},
],
}, [
{ crop: 'corn', yield_t: 2 }, // okay
{ crop: 'barley', yield_t: 2 }, // okay
{ crop: 2, yield_t: 'ten' }, // error!
//~~~~ ~~~~~~~ <-- Type 'string' is not assignable to type 'number'
// ^-- Type 'number' is not assignable to type 'string'
])
看起来不错。编译器期望第二个参数是
{crop: string; yield_t: number}
对象的数组,因此最后一个元素是不正确的。