Issue
Here's a factory class, which constructs objects, making use of the "correlated union" pattern:
interface Factory<FT, N extends keyof FT> {
(f: CompositeFactory<FT>): FT[N]
}
// The following two types are motivated by allowing the return type of
// CompositeFactory.make to be determined by its `name` argument type, using the
// correlated union pattern.
type FactoryWithName<FT, K extends keyof FT> = {
[N in K]: {
name: N
factory: Factory<FT, N>
}
}[K]
export type FactoriesWithNameByName<FT> = { [N in keyof FT]: FactoryWithName<FT, N> }
// Motivated by making constructing CompositeFactory concise
export type FactoriesObject<FT> = { [N in keyof FT]: Factory<FT, N> }
export class CompositeFactory<FT> {
private factories: FactoriesWithNameByName<FT>
constructor(factories: FactoriesObject<FT>) {
this.factories = Object.fromEntries(
Object.entries(factories).map(([n, f]) => [n, {name: n, factory: f}])
) as FactoriesWithNameByName<FT>
}
make<N extends keyof FT>(name: N): FT[N] {
return this.factories[name].factory(this)
}
}
Currently, that can be used like this (with an explicit type declaration on the first line):
const factories: FactoriesObject<{
foo: number
thing: { size: number }
}> = {
foo: () => 123,
thing: (f) => {
const x = f.make("foo")
return { size: x }
},
}
const f = new CompositeFactory(factories)
const thing = f.make("thing")
console.log(JSON.stringify(thing))
I'd like to use it something like this (without the explicit type declaration, since that information is present in the factory functions):
const factories2 = {
foo: () => 123,
thing: (f) => { // Error: Parameter 'f' implicitly has an 'any' type.
const x = f.make("foo")
return { size: x }
},
}
const f2 = new CompositeFactory(factories2)
const thing2 = f.make("thing")
console.log(JSON.stringify(thing2))
Of course I can add an explicit f: any
type declaration to make the error go away, but I'd prefer if the compiler knew the precise type in the implementation of that function.
Is that, or something like it, possible?
Solution
If you don't want to explicitly annotate the type of factories
as FactoriesObject<{ foo: number; thing: { size: number }}>
then you'll have to figure out how to get the TypeScript compiler to infer it. Maybe you'd like to be able to say something like
// not valid TS, don't do this
const factories: FactoriesObject<infer> = {
foo: () => 123,
thing: (f) => {
const x = f.make("foo")
return { size: x }
},
}
where infer
would tell the compiler to infer the type argument T
to FactoriesObject
, and then the compiler would use this context to also contextually infer that f
is of type CompositeFactory<T>
.
There are two problems with this, though.
The first problem is that you can't get the compiler to infer type arguments in generic types from values of those types. That FactoriesObject<infer>
syntax is not valid. There's an open issue at microsoft/TypeScript#32794 asking for some way to do this, but so far it's not part of the language.
This problem can be overcome though, by replacing const factories: FactoriesObject<infer> = {...}
by const factories = asFactoriesObject({...})
for an appropriately defined asFactoriesObject
helper function:
const asFactoriesObject = <T,>(fo: FactoriesObject<T>) => fo;
You can't get the compiler to infer type arguments in generic types, but you can get it to infer type arguments when calling generic functions. So the above asFactoriesObject()
is a generic function that returns its input as-is, and lets you use the compiler's inference abilities to get a value of type FactoriesObject<T>
for an inferred T
.
Like this:
const factories = asFactoriesObject({
foo: () => 123,
thing: (f) => {
// (parameter) f: CompositeFactory<{ foo: number; thing: unknown; }>
const x = f.make("foo")
return { size: x }
},
});
/* const factories: FactoriesObject<{
foo: number;
thing: unknown;
}> */
const f = new CompositeFactory(factories)
const thing = f.make("thing") // unknown 🙁
So T
is inferred, 👍! But it's inferred as {foo: number; thing: unknown}
instead of { foo: number; thing: { size: number }}
, 👎.
Which brings us to the second problem. Without explicitly annotating the type argument to FactoriesObject
and asking the compiler to infer it, you've set up a circular inference task that the compiler isn't great at solving. The type of thing
depends on the type of f
which depends on the type of thing
. The compiler gives up and infers unknown
there instead of {size: number}
.
This sort of limitation is hard to completely eradicate. There have actually been some improvements to simultaneous inference of callback parameter types and generic type arguments recently, but those involve situations where each callback only depends on previous properties in the object. Whereas here, each callback depends, at least by call signature, on the entire object. Without something like a full unification algorithm as discussed in microsoft/TypeScript#30134, this is just not possible.
So what can we do instead if you don't want to explicitly annotate the types yourself? Well, presumably you'll never really have each property depend on every other property. Instead you probably have an acyclic chain where some properties depend on previous properties in the chain. And if so, you can replace the simple one-shot helper function with a builder that assembles the full object from each piece of the chain, giving you inference along the way.
There are lots of ways to implement this, but I'll present one which ends up passing the composed object to the CompositeFactory
constructor at the end. You could put this inside CompositeFactory
if you want. You could make it so that the links in the chain are passed by separate key/value arguments instead of by sub-objects. There are probably more possibilities. The idea here is just to show you how it's possible to get the inference you want:
interface CompositeFactoryBuilder<T extends object> {
and<U extends { [K in keyof U]: K extends keyof T ? never : unknown }>(
fo: { [K in keyof U]: (f: CompositeFactory<T>) => U[K] }
): CompositeFactoryBuilder<{ [K in keyof (T & U)]: (T & U)[K] }>,
build(): CompositeFactory<T>
}
function compositeFactoryBuilderOf<T extends object>(
fo: { [K in keyof T]: (f: CompositeFactory<{}>) => T[K] }
): CompositeFactoryBuilder<T> {
return ({
and(fo2) {
return compositeFactoryBuilderOf({ ...fo, ...fo2 } as any);
},
build() {
return new CompositeFactory(fo);
}
});
}
So the compositeFactoryBuilderOf()
takes an initial object whose methods don't depend on anything (they accept CompositeFactory<{}>
which might as well not even be an argument) and returns a CompositeFactoryBuilder<T>
object for an appropriate type T
. This object has an and()
method which will take another object whose methods accept CompositeFactory<T>
, and return something like a CompositeFactoryBuilder<T & U>
for the original T
and the extra properties represented by U
. You can keep calling and()
and pass extra links in the chain. When you're done you can call build()
, which gives you the CompositeFactory<T & U & V & ....>
for the whole thing.
Let's test it:
const f = compositeFactoryBuilderOf({ foo: () => 123 }).
and({ thing: f => { const x = f.make("foo"); return { size: x } } }).
// ^<-- f: CompositeFactory<{ foo: number }>
build();
/* const f: CompositeFactory<{
foo: number;
thing: {
size: number;
};
}> */
const thing = f.make("thing")
console.log(JSON.stringify(thing)) // {size: 123}
Looks good. The call to compositeFactoryBuilderOf({foo: ()=>123})
produces a CompositeFactoryBuilder<{foo: number}>
. Then in the call to and()
, the callback in the thing
property is contextually inferred to have its f
parameter be of type CompositeFactory<{foo: number}>
. This produces a CompositeFactoryBuilder<{foo: number; thing: {size: number}}>
. That's what we wanted, so we call build()
, giving us the final CompositeFactory<{foo: number; thing: {size: number}}>
, which behaves as expected.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.