Generic TypeScript interface with mixed member types

≯℡__Kan透↙ 提交于 2019-12-07 21:10:33

问题


For several HTML forms, I want to configure inputs and deal with their values. My types have the following structure (you may copy it to TypeScript Playground http://www.typescriptlang.org/play to see it in action):

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

The most important advantage of this generic approach is the single point of field naming in the Inputs interface: The names username, password, passwordConfirmation,... are used by both InputConfigs and InputValues which makes renaming as easy as possible.

Unfortunately, it does not work as expected since the last assignment const username: string = values.username is not accepted by the compiler:

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

I expected it to work because my understanding was:

  • case 1: T is InputConfig and thus username is of type InputConfig because:
    • T & InputConfig = InputConfig & InputConfig = InputConfig
    • T & string = InputConfig & string = nothing
  • case 2: T is string | string[] and thus username is of type string because:
    • T & InputConfig = (string | string[]) & InputConfig = nothing
    • T & string = (string | string[]) & string = string

回答1:


First of all, let's explore the type error you're getting. If I ask TypeScript for the type of values.username (by hovering over it in an editor) I get

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

TypeScript has come up with this type by instantiating Inputs's T parameter to string | string[] and putting username's type into disjunctive normal form:

(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)

Is a value of this type assignable to string? No! username could be a string[] & InputConfig, so const x : string = values.username is ill typed.

Contrast this with your other type,

configs.username : InputConfig | (InputConfig & string)

This type is assignable to InputConfig.

This is a shortcoming of type systems in general: they have a tendency to reject programs which nonetheless would work at runtime. You may know that values.username will always be a string, but you haven't proved it to the satisfaction of the type system, which is just a dumb mechanical formal system. I'd generally argue that well typed programs tend to be easier to understand, and (crucially) easier to keep working as you change the code over time. Nonetheless, working within an advanced type system like TypeScript is an acquired skill, and it's easy to get trapped down a blind alley trying to make the type system do what you want.


How can we fix this? I'm a little unclear on what you're trying to achieve, but I'm interpreting your question as "how can I reuse the field names of a record with a variety of different types?" This seems like a job for mapped types.

Mapped types are an advanced feature of TypeScript's type system, but the idea is simple: you declare your field names up front as a union of literal types, and then describe a variety of types derived from those field names. Concretely:

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

Now, InputConfigs is a record with all of those InputFields, each typed as a (readonly) InputConfig. The TypeScript library provides some useful type combinators for this:

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

Readonly and Record are defined respectively as:

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

When applied to the types in question we get:

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 is a bit trickier, because it doesn't map those field names to types in a uniform manner. hobbies is a string[] whereas all the other fields are strings. I don't wanna use Record<InputFields, string | string[]> because that throws away your knowledge of which fields are strings and which are string[]s. (Then you'd be stuck with the same type error as you had in the question.) Instead, let's flip this around and treat InputValues as the source of truth about the field names, and derive InputFields from it using 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>>

Now your code type checks without modification.

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! 🎉

If you have a lot of these types and you want to map them all to config types, I'd even go so far as to define a generic type combinator for deriving types like InputConfigs from InputValues:

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

type InputConfigs = Config<InputValues>



回答2:


Whenever you have a union Foo | Bar and you want to use it as Foo you have two options:

Use a type guard

In your case you just want it to be a string change

const username: string = values.username // ERROR

to

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

Use a type assertion

const username: string = values.username as string;

Note a type assertion is you telling the compiler "I know better".



来源:https://stackoverflow.com/questions/44168636/generic-typescript-interface-with-mixed-member-types

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!