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:
tsTryclassWebpackCompiler {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
tsTryconstcfg = {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:
tsTryclassFruitStand {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:
tsTrytypeConstructorArg <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
tsTrytypeConstructorArg <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:
tstype 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:
tstype 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
tstype 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.
tstype 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
tsTryletmyValue : 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?
tsTryletmyValue : string | number | never
Great! Let’s go back to our ConstructorArg<C> type and add this in
tsTrytypeConstructorArg <C > =C extends {new (arg : inferA , ...args : any[]): any}?A : never
And we’re done!
tsTryletdateFirst :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!!}
tsTrytypeConstructorArg <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!