问题
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
isInputConfig
and thususername
is of typeInputConfig
because:T & InputConfig
=InputConfig & InputConfig
=InputConfig
T & string
=InputConfig & string
= nothing
- case 2:
T
isstring | string[]
and thususername
is of typestring
because:T & InputConfig
=(string | string[]) & InputConfig
= nothingT & 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 string
s. I don't wanna use Record<InputFields, string | string[]>
because that throws away your knowledge of which fields are string
s 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