Issue
I am trying to build a class, and it has a field model
that can be extended according to which plugins have been registered on the class.
I try to use conditional types to give the field some type hints so users can use it easily: I want the type of service.models
change on every .use()
call.
I have the following, but it is never working, and also getting error like: Type 'Service<Models>' is not assignable to type 'NewService extends Service<infer NewModels extends ModelBase> ? Service<NewModels & Models> : this'.
export type MaybePromise<T> = T | Promise<T>
type ModelBase = Record<string, string>
export interface ModelList {
models(): MaybePromise<Record<string, string>>
}
class ModelListImpl implements ModelList {
models() {
return {
model_1: 'model-1',
model_2: 'model-2',
}
}
}
class Service<Models extends ModelBase = Record<string, never>> {
models: Models = {} as Models
// eslint-disable-next-line @typescript-eslint/no-explicit-any
use<NewService extends Service<any> = this>(service: ModelList): NewService extends Service<infer NewModels> ? Service<NewModels & Models> : this {
const models = service.models()
const newModels = {
...this.models,
...models
}
this.models = newModels
return this
}
}
let service = new Service()
service = service.use(new ModelListImpl())
// this should be typed {model_1: ..., model_2: ...}
service.models
Solution
A major problem with the implementation of your example code is that the models
property of Service
is a MaybePromise
, and the models()
method of ModelList
also returns a MaybePromise
, so the code
const newModels = {
...this.models, // MaybePromise
...models // MaybePromise
}
has a very good chance of trying to use object spread on at least one Promise
, which is not going to work. If either one is a Promise
then you need to write some asynchronous logic to combine them into a new Promise
. One way to do that looks like
const oldModels = this.models;
const newModels = (("then" in models) || ("then" in oldModels)) ?
(async () => ({ ...(await oldModels), ...(await models) }))() :
{ ...oldModels, ...models, };
where if either oldModels
or models
are a Promise
(determined by the presence of a "then"
property), we return an immediately-executed async
function expression, in which the pieces to spread are await
ed.
Do note well that once you have asynchronous code, order of operations becomes harder to reason about. It is very important that we use a new variable oldModels
instead of just this.models
, because if you assign this.models = newModels
inside the function, then by the time the async
code actually runs, this.models
will quite likely point to newModels
and then you'll have a Promise
waiting for itself.
Anyway, now that the implementation will work at runtime, we should get the types right.
Here's one approach:
interface ModelList<T extends ModelBase> {
models(): MaybePromise<T>
}
class Service<M extends ModelBase = {}> {
models: MaybePromise<M> = {} as M
use<L extends ModelBase>(service: ModelList<L>): Service<L & M> {
const models = service.models();
const oldModels = this.models;
const newModels: MaybePromise<L & M> =
(("then" in models) || ("then" in oldModels)) ?
(async () => ({ ...(await oldModels), ...(await models) }))() :
{ ...oldModels, ...models, };
this.models = newModels;
return this as Service<M> as Service<L & M>;
}
}
I've added a generic type parameter to ModelList
so that it knows about the return type of models()
. Then, inside Service<M>
(I'm using the short M
instead of Models
for the type parameter), use()
accepts a ModelList<L>
(I'm using the short L
, for ModelList
, instead of NewService
for the type parameter). Then use()
returns a Service<L & M>
, combining both the new service models type and the old one.
You need a few type assertions, especially because we're mutating this
instead of returning a new Service
, and TypeScript can't easily represent such mutations at the type level. Oh and also note that I'm using {}
instead of Record<string, never>
, since we want to say that the default model has no known elements, not no elements at all forever.
Okay, let's test it out:
let service = new Service();
// ^? let service: Service<{}>
class ModelListImpl {
models() {
return {
model_1: 'model-1',
model_2: 'model-2',
}
}
}
let service2 = service.use(new ModelListImpl())
// ^? let service2: Service<{ model_1: string; model_2: string; }>
Note that I created a new service2
variable instead of reusing service
. The type of service
is always and forever Service<{}>
. Again, TypeScript doesn't represent mutations at the type level, so even if you assign the result of use()
to service
, the type of service
will not change to reflect that. It's therefore better to use a new variable.
Okay, let's consume service2.models
, which needs to be written to handle a MaybePromise
:
if ("then" in service2.models) {
service2.models.then(v =>
console.log("was a promise", v.model_1.toUpperCase()));
} else {
console.log("was not a promise", service2.models.model_1.toUpperCase());
}
// "was not a promise", "MODEL-1"
Looks good. The compiler understands that model_1
exists on either directly service2.models
or the resolved value of the promise. Because neither the original service.models
nor ModelList
's models
return type is a promise, then service2.models
is also not a promise.
Now let's see what happens when we provide a ModelList
that actually returns a Promise
for models()
:
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
let service3 = service2.use({ async models() {
// ^? let service3: Service<{ model_3: string; } & { model_1: string; model_2: string; }>
await sleep(1000); return { model_3: "model-3" } } });
if ("then" in service3.models) {
service3.models.then(v => console.log("was a promise",
v.model_1.toUpperCase(), v.model_3.toUpperCase()));
} else {
console.log("was not a promise",
service3.models.model_1.toUpperCase(), service3.models.model_3.toUpperCase());
}
// "was a promise", "MODEL-1", "MODEL-3"
Now service3
is a Service<{ model_3: string } & { model_1: string; model_2: string }>
, and when we check service3.models
, we see (after one second) that it is a promise that eventually resolves to a { model_3: string } & { model_1: string; model_2: string }
, as expected.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.