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 awaited.
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.