具有混合成员类型的通用 TypeScript 接口

Generic TypeScript interface with mixed member types

对于几个 HTML 表单,我想配置输入并处理它们的值。我的类型具有以下结构(您可以将其复制到 TypeScript Playground http://www.typescriptlang.org/play 以查看实际效果):

interface InputConfig {
    readonly inputType: "text" | "password" | "list"
    readonly attributes?: ReadonlyArray<string>
    readonly cssClasses?: ReadonlyArray<string>
}

interface Inputs<T extends InputConfig | string | string[]> {
    readonly username: (T & InputConfig) | (T & string)
    readonly password: (T & InputConfig) | (T & string)
    readonly passwordConfirmation: (T & InputConfig) | (T & string)
    readonly firstname: (T & InputConfig) | (T & string)
    readonly lastname: (T & InputConfig) | (T & string)
    readonly hobbies: (T & InputConfig) | (T & string[])
}

type InputConfigs = Inputs<InputConfig> // case 1 (see below)
type InputValues = Inputs<string | string[]> // case 2 (see below)

const configs: InputConfigs = {
    username: {
        inputType: "text"
    },
    password: {
        inputType: "password"
    },
    passwordConfirmation: {
        inputType: "password"
    },
    firstname: {
        inputType: "text"
    },
    lastname: {
        inputType: "text"
    },
    hobbies: {
        inputType: "list"
    }
}

const values: InputValues = {
    username: "testuser1",
    password: "test1",
    passwordConfirmation: "test1",
    firstname: "Tester",
    lastname: "1",
    hobbies: ["a", "b", "c"]
}

const username: string = values.username

这种通用方法最重要的优点是 Inputs 界面中的单点字段命名:名称 usernamepasswordpasswordConfirmation。 .. 被 InputConfigs InputValues 使用,这使得重命名尽可能容易。

不幸的是,由于编译器不接受最后一个赋值 const username: string = values.username,因此它没有按预期工作:

Type 'string | (string & InputConfig) | (string[] & InputConfig) | (string[] & string)' is not assignable to type 'string'.
  Type 'string[] & InputConfig' is not assignable to type 'string'.

我预计它会起作用,因为我的理解是:

每当你有一个联合 Foo | Bar 并且你想将它用作 Foo 你有两个选择:

使用类型保护

在您的情况下,您只希望它是一个 string 更改

const username: string = values.username // ERROR

const username: string = typeof values.username === 'string' ? values.username : ''; // OKAY

使用类型断言

const username: string = values.username as string;

注意一个type assertion is you telling the compiler "I know better"

首先,让我们探讨一下您遇到的类型错误。如果我向 TypeScript 询问 values.username 的类型(通过在编辑器中将鼠标悬停在它上面),我得到

values.username : string
                | (string & InputConfig)
                | (string[] & InputConfig)
                | (string[] & string)

TypeScript 通过将 InputsT 参数实例化为 string | string[] 并将 username 的类型放入析取范式中来提出这种类型:

(T & InputConfig) | (T & string)
// set T ~ (string | string[])
((string | string[]) & InputConfig) | ((string | string[]) & string)
// distribute & over |
((string & InputConfig) | (string[] & InputConfig)) | ((string & string) | (string[] & string))
// reduce (string & string) ~ string, permute union operands
string | (string & InputConfig) | (string[] & InputConfig) | (string[] & string)

是否可以将此类型的值分配给 string?不! username 可能是 string[] & InputConfig,所以 const x : string = values.username 是错误类型。

将此与您的其他类型进行对比,

configs.username : InputConfig | (InputConfig & string)

此类型可分配给 InputConfig

这通常是类型系统的缺点:它们倾向于拒绝在运行时仍然可以运行的程序。 可能知道values.username永远是一个string,但你还没有证明它对类型系统满意,这只是一个愚蠢的机械正式系统。我通常认为,类型良好的程序往往更容易理解,并且(至关重要的)更容易 继续工作 随着时间的推移更改代码。尽管如此,在像 TypeScript 这样的高级类型系统中工作是一项后天技能,很容易陷入死胡同,试图让类型系统做你想做的事。


我们如何解决这个问题?我不太清楚您要实现的目标,但我将您的问题解释为 "how can I reuse the field names of a record with a variety of different types?" 这似乎是 mapped types.

的工作

映射类型是 TypeScript 类型系统的一项高级功能,但其思想很简单:您预先将字段名称声明为文字类型的联合,然后描述从这些字段名称派生的各种类型。具体来说:

type InputFields = "username"
                 | "password"
                 | "passwordConfirmation"
                 | "firstname"
                 | "lastname"
                 | "hobbies"

现在,InputConfigs 是一条包含所有这些 InputFields 的记录,每个记录都键入为 (readonly) InputConfig。 TypeScript 库为此提供了一些 useful type combinators

type InputConfigs = Readonly<Record<InputFields, InputConfig>>

Readonly and Record分别定义为:

type Readonly<T> = {
    readonly [P in keyof T]: T[P];
}
type Record<K extends string, T> = {
    [P in K]: T;
}

当应用于有问题的类型时,我们得到:

type InputConfigs = Readonly<Record<InputFields, InputConfig>>
// expand definition of Record
type InputConfigs = Readonly<{ [P in InputFields]: InputConfig; }>
// expand definition of Readonly
type InputConfigs = {
    readonly [Q in keyof { [P in InputFields]: InputConfig }]: { [P in InputFields]: InputConfig }[Q];
}
// inline keyof and subscript operators
type InputConfigs = {
    readonly [Q in InputFields]: InputConfig;
}
// expand InputFields indexer
type InputConfigs = {
    readonly username: InputConfig;
    readonly password: InputConfig;
    readonly passwordConfirmation: InputConfig;
    readonly firstname: InputConfig;
    readonly lastname: InputConfig;
    readonly hobbies: InputConfig;
}

InputValues 有点棘手,因为它不会以统一的方式将这些字段名称映射到类型。 hobbiesstring[] 而所有其他字段都是 string。我不想使用 Record<InputFields, string | string[]>,因为那样会让您失去对哪些字段是 string 哪些字段是 string[] 的了解。 (然后你会遇到与问题中相同的类型错误。)相反,让我们翻转一下并将 InputValues 视为字段名称的真实来源,并派生 InputFields从它使用 the keyof type operator.

type InputValues = Readonly<{
    username: string
    password: string
    passwordConfirmation: string
    firstname: string
    lastname: string
    hobbies: string[]
}>
type InputFields = keyof InputValues
type InputConfigs = Readonly<Record<InputFields, InputConfig>>

现在您的代码类型检查无需修改。

const configs: InputConfigs = {
    username: {
        inputType: "text"
    },
    password: {
        inputType: "password"
    },
    passwordConfirmation: {
        inputType: "password"
    },
    firstname: {
        inputType: "text"
    },
    lastname: {
        inputType: "text"
    },
    hobbies: {
        inputType: "list"
    }
}

const values: InputValues = {
    username: "testuser1",
    password: "test1",
    passwordConfirmation: "test1",
    firstname: "Tester",
    lastname: "1",
    hobbies: ["a", "b", "c"]
}

let usernameConfig: InputConfig = configs.username  // good!
let usernameValue: string = values.username  // great!
let hobbiesValue: string[] = values.hobbies  // even better! 

如果你有很多这样的类型并且你想将它们全部映射到配置类型,我什至会定义一个泛型类型组合器来从 [= 派生像 InputConfigs 这样的类型38=]:

type Config<T> = Readonly<Record<keyof T, InputConfig>>

type InputConfigs = Config<InputValues>