Issue
I need to pass a configuration object to a function, where the is
field defines the prototype of the object being created, and the options
field defines the properties of the first constructor argument, here for example:
{
testClassA: {
is: ClassA, // class ClassA { constructor(options: {foo: string}) }
options: {
foo: 'bar'
}
},
testClassB: {
is: ClassB, // class ClassB extends ClassA { constructor(options: {bar: number}) }
options: {
bar: 1
}
}
}
This is how I want to implement lazy on-demand object creation. The problem is that the entry U extends typeof ClassA
limits the valid values of the is
property to the ClassA
class only, and I want any classes extending it to be accepted.
Here is a link to the sandbox.
Any help would be appreciated.
UPD: Full example source code:
class ClassA {
foo: string
constructor(options: {foo: string}) {
this.foo = options.foo
}
}
class ClassB extends ClassA {
constructor(options: {bar: number}) {
super({foo: ''})
}
}
function create<U extends typeof ClassA, A extends {[k in string]: {is: U} & {options: ConstructorParameters<U>[0]} }> (config: {
classes: A
}) {}
create({
classes: {
testClassA: {
is: ClassA,
options: {
foo: 'bar'
}
},
testClassB: {
is: ClassB,
options: {
bar: 1
}
}
}
})
Solution
If you want the compiler to infer a generic function's type parameters when you call the function, you should make sure there are good inference sites for these type parameters. Let's look at a simple case of a good inference site:
declare function foo<T>(x: T): void;
// good inference site: ^^^^
foo(new Date()); // function foo<Date>(x: Date): void
Here, the type of x
is an inference site for T
, since the value passed in for x
when you call foo()
can be used to infer T
. And it's a very good inference site: the type of x
is T
, and you want the compiler to infer T
. That means the compiler can just use the type of x
as a candidate for T
without having to do any more complicated inference tricks. As you can see, in foo(new Date())
, T
is inferred as Date
.
On the other end of the spectrum, you have this:
declare function bar<T>(x: string): void;
// no inference site: ^^^^^^^^^^^^^^^^^^
bar("oops"); // function bar<unknown>(x: string): void
There is no inference site at all for T
. The call to bar("oops")
doesn't give the compiler anything from which it can begin to infer T
, and so the inference fails and falls back to an implicit constraint of unknown
.
Most generic functions fall somewhere between these extremes:
declare function baz<T>(x: T | string): void;
// inference site: ^^^^^^^^^^^^^
baz(Math.random() < 0.5 ? 0 : "");
// function baz<number>(x: string | number): void
baz("hello");
// function baz<string>(x: string): void
Here the type of x
is T | string
, and given a value of type T | string
you want the compiler to infer T
. In the first call, we passed in a value of the type string | number
, and the compiler successfully infers number
as the type for T
. So it does something like Exclude<number | string, string>
to get number
. But in the second call, we passed in a value of type string
, and the compiler infers string
as the type for T
instead of Exclude<string, string>
or never
. Both inferences are "correct" in that they work: string | string
is just string
, but depending on the use case you may or may not like the inconsistency there.
One more example and then I'll stop:
declare function qux<T>(x: { [K in keyof T]: Array<T[K]> }): void;
// inference site ----> ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
qux({ a: [1, 2, 3], b: ["x", "y"], c: [true, false] });
// function qux<{ a: number; b: string; c: boolean;}>(
// x: { a: number[]; b: string[]; c: boolean[]; }
// ): void
Here we have what might look like a bad inference site: we need to infer T
from a value of {[K in keyof T]: Array<T[K]>}
, a mapped type where each property in T
is wrapped in Array<>
. But amazingly the compiler succeeds! You pass in a value of type {a: number[], b: string[], c: boolean[]}
and the compiler undoes the mapping and produces a T
of {a: number, b: string, c: boolean}
. This is known as inference from mapped types (sorry there's no non-deprecated handbook link for that) and it only works because the mapped type is homomorphic, meaning we're mapping over properties of some object type (you can tell by the presence of in keyof
). And the compiler is quite good at doing this.
Anyway, if you find that inference doesn't happen the way you want, you might need to improve your inference sites by moving away from things it can't infer toward things it can. This is somewhat hand-wavy advice, since it takes some experience to know what can and can't be inferred well. If all else fails, make your type parameter the same as the type of the passed-in function argument and then compute the type you really wanted from it.
Let's look at your version of create
's call signature:
function create<
U extends typeof ClassA,
A extends { [k: string]: { is: U } & { options: ConstructorParameters<U>[0] } }
>(config: { classes: A }) { }
The first thing that immediately jumps out at me is that there is no inference site for U
. You might think that the constraint for A
would be such an inference site, but that's not how it works. See this comment on microsoft/TypeScript#44711 for an authoritative statement that generic constraints are not inference sites in TypeScript.
That means inference for U
will always fail and fallback to typeof ClassA
, so your code is equivalent to
function create<
A extends { [k: string]: { is: typeof ClassA } & { options: { foo: string } } }
>(config: { classes: A }) { }
The good news is that you have a very good inference site for A
, but the bad news is that you don't want A
to be constrained this way. You want each property of A
to possibly have its own one-arg subclass contructor of ClassA
, which has a possibly different constructor argument from {foo: string}
. You do want to allow different contructor arguments, and you don't want to allow two different constructor types for the is
and the options
properties for any particular property of A
.
So, instead of having both is
and options
inside the type parameter constraint, let's just have the type parameter be a straightforward mapping from key names to one-arg constructors of ClassA
instances, and use a mapped type to represent the is
and options
stuff:
function create<A extends { [K in keyof A]: new (options: any) => ClassA }>(config: {
classes: { [K in keyof A]: { is: A[K], options: ConstructorParameters<A[K]>[0] } }
}) { }
Here the constraint is recursive since it is of the form A extends {[K in keyof A]: ...}
. But all it means is that we do not care about the keys of A
, and the compiler accepts it. The constraint's property type is new (options: any) => ClassA
, which is less specific than typeof ClassA
since we don't care about the constructor argument type (except that the constructor doesn't need more than one argument) and we don't care about any static
properties of the constructor either.
The mapped type for the classes
property of config
takes each property A[K]
of A
, which is some one-arg constructor of ClassA
-or-a-subtype instances, and maps it to { is: A[K], options: ConstructorParameters<A[K]>[0] }
. So the is
property is just that constructor type, and the options
property is that constructor's first (and only necessary) constructor argument.
So, does it work?
create({
classes: {
testClassA: {
is: ClassA,
options: {
//^? (property) options: {foo: string;}
foo: 'bar'
}
},
testClassB: {
is: ClassB,
options: {
//^? (property) options: {bar: number;}
bar: 2
}
}
}
})
Yes, looks good. Let's make sure it gets mad if we do the wrong thing:
create({
classes: {
oops: {
is: ClassB, options: { foo: "bar" } // error!
// ------------------> ~~~~~~~~~~
// Type '{ foo: string; }' is not assignable
// to type '{ bar: number; }'
}
}
})
Hooray!
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.