Conditional types are not just for switching behavior based on comparison — they can be used with an ‘infer’ keyword to access sub-parts of type information within a larger type
Type inference in conditional types
In the same release where conditional types were added to TypeScript
a new infer
keyword was added as well. This keyword, which can only be used
in the context of a condition expression (within a conditional type declaration)
is an important tool for being able to extract out pieces of type information
from other types.
A motivating use case
Let’s consider a practical example: a class whose constructor wants a complicated options object, but doesn’t export the type for this object as an interface or type alias:
tsTry
classWebpackCompiler {constructor(options : {amd ?: false | { [index : string]: any }bail ?: booleancache ?:| boolean| {maxGenerations ?: numbertype : "memory"}context ?: stringdependencies ?: string[]devtool ?: string | falseentry ?: stringchunkLoading ?: string | falsedependOn ?: string | string[]layer ?: null | stringruntime ?: stringwasmLoading ?: string | falseexternalsType ?:| "var"| "module"| "assign"| "this"| "window"| "self"| "global"| "commonjs"| "commonjs2"| "commonjs-module"| "amd"| "amd-require"| "umd"| "umd2"| "jsonp"| "system"| "promise"| "import"| "script"ignoreWarnings ?: (|RegExp | {file ?:RegExp message ?:RegExp module ?:RegExp })[]loader ?: { [index : string]: any }mode ?: "development" | "production" | "none"name ?: stringparallelism ?: numberprofile ?: booleanrecordsInputPath ?: string | falserecordsOutputPath ?: string | falserecordsPath ?: string | falsestats ?:| boolean| "none"| "summary"| "errors-only"| "errors-warnings"| "minimal"| "normal"| "detailed"| "verbose"target ?: string | false | string[]watch ?: boolean}) {}}
If, in our own code, we want to declare a variable to store our configuration
before passing it into the compiler, we’re in trouble. Take a look below
at how a spelling error around the word watch
could create a tricky bug
tsTry
constcfg = {entry : "src/index.ts",wutch : true, // SPELLING ERROR!!}try {constcompiler = newWebpackCompiler (cfg )} catch (err ) {throw newError (`Problem compiling with config\n${JSON .stringify (cfg ,null," ")}`)}
What we really want here is the ability to extract that constructor argument
out, so that we can obtain a type for it directly. Once we have that type, we’ll
be able to change our code above to const cfg: WebpackCompilerOptions
and we’ll
have more complete validation of this object.
The infer
keyword
The infer
keyword gives us an important tool to solve this problem — it lets
us extract and obtain type information from larger types.
Let’s simplify the problem we aim to solve, by working with a much simpler class:
tsTry
classFruitStand {constructor(fruitNames : string[]) {}}
What we want is some kind of thing, where FruitStand
is our
input, and string[]
is our result. Here’s how we can make that happen:
tsTry
typeConstructorArg <C > =C extends {new (arg : inferA , ...args : any[]): any}?A : never
First, let’s establish that this works, and then we’ll unpack the syntax so that we can understand exactly what’s going on
tsTry
typeConstructorArg <C > =C extends {new (arg : inferA ): any}?A : neverclassFruitStand {constructor(fruitNames : string[]) {}}// our simple exampleletfruits :ConstructorArg <typeofFruitStand >// our more realistic exampleletcompilerOptions :ConstructorArg <typeofWebpackCompiler >
Ok, this is great — let’s take a close look at how we did it by stepping through the syntax
First, we are creating a generic type, with a type param C
which could be anything:
ts
type ConstructorArg<C> ...
Next, we’re beginning to define a conditional type, using the
ternary operator syntax. We want to do something special if C
looks
like the static side of a class (the type with a constructor).
{ new (...args: any[]): any }
is a type that matches any
constructor signature, regardless of what arguments it may take, and
what it instantiates:
ts
type ConstructorArg<C> = C extends {new (...args: any[]): any}...
Next, we want to “collect” the first constructor argument. This is
where the new infer
keyword comes into play.
diff
- new (...args: any[]): any+ new (arg: infer A, ...args: any[]): any
It kind of looks like we’re using a new type param (A
) without
including it next to <C>
in the type parameter list. We also
have an infer
keyword to the left of A
ts
type ConstructorArg<C> = C extends {new (arg: infer A, ...args: any[]): any}? ...: ...
We should take note that our condition for this conditional type has changed. It will no longer match zero-argument constructors, but that’s fine because there’s nothing to extract in that case.
In the case where our condition matches type C
, we’ll return the argument
of type A
that we “extracted” using that infer
keyword.
ts
type ConstructorArg<C> = C extends {new (arg: infer A, ...args: any[]): any}? A: ...
And finally, in the case where type C
is not a class we need
to decide which type to “emit”. Ideally this will be something that,
when used in a Union type (|
), will kind of “disappear”.
ts
// for type `X` we're trying to figure out, we want...;string | number | X // should just be `string | number`
What about any
? Let’s see how that behaves
tsTry
letmyValue : string | number | any
That’s not just the wrong result, it’s kind of the opposite result
of what I was looking for. any
, when used in a Union type, kind of
swallows everything else in the union.
If any
gives us the opposite of what we want, maybe the opposite of
any
(never
) will give us exactly what we’re looking for?
tsTry
letmyValue : string | number | never
Great! Let’s go back to our ConstructorArg<C>
type and add this in
tsTry
typeConstructorArg <C > =C extends {new (arg : inferA , ...args : any[]): any}?A : never
And we’re done!
tsTry
letdateFirst :ConstructorArg <typeofDate >letpromiseFirst :ConstructorArg <typeofPromise >letwebpackCfg :ConstructorArg <typeofWebpackCompiler >
Awesome! Now if we go back to the original thing we were trying to do, we get some improved type safety
diff
-const cfg = {+const cfg: ConstructorArg<typeof WebpackCompiler> = {entry: "src/index.ts",wutch: true, // SPELLING ERROR!!}
tsTry
typeConstructorArg <C > =C extends {new (arg : inferA , ...args : any[]): any}?A : neverconstcfg :ConstructorArg <typeofWebpackCompiler > = {entry : "src/index.ts",Object literal may only specify known properties, but 'wutch' does not exist in type '{ amd?: false | { [index: string]: any; } | undefined; bail?: boolean | undefined; cache?: boolean | { maxGenerations?: number | undefined; type: "memory"; } | undefined; context?: string | undefined; ... 20 more ...; watch?: boolean | undefined; }'. Did you mean to write 'watch'?2561Object literal may only specify known properties, but 'wutch' does not exist in type '{ amd?: false | { [index: string]: any; } | undefined; bail?: boolean | undefined; cache?: boolean | { maxGenerations?: number | undefined; type: "memory"; } | undefined; context?: string | undefined; ... 20 more ...; watch?: boolean | undefined; }'. Did you mean to write 'watch'?: true, // SPELLING ERROR!! wutch }try {constcompiler = newWebpackCompiler (cfg )} catch (err ) {throw newError (`Problem compiling with config\n${JSON .stringify (cfg ,null," ")}`)}
Success!