Issue
So we have like:
Dictwhich is{ [key: string]: () => any }Xwhich is the return I want
And I'm trying to create a type for a function that:
- receives a dictionary
DictT - returns an
X
Now, X is also a function, but this one:
- receives a
DictU - returns itself (
X) - we can access all the properties of
Uplus all the properties ofTthat aren't overwritten by properties ofU - and the return value keeps reference of all the keys inside, so we can't access keys not defined in any of the previous chains
The function is bellow, any I'm not quite sure how to type it...
export function lazylet(values) {
const createStore = (overrides) => {
return lazylet({
...values,
...overrides,
});
};
Object.entries(values).map(([key, factory]) => {
Object.defineProperty(createStore, key, {
enumerable: true,
configurable: true,
get() {
const value = factory()
Object.defineProperty(createStore, key, { get: () => value });
return value;
},
})
});
return createStore;
};
An example of the use I'm looking for is something like this:
const laz1 = lazylet({ a: () => "hello", b: () => Math.random() })
/* const laz1: LazyLet<{
a: string;
b: number;
}> */
console.log(laz1.a.toUpperCase()) // "HELLO"
console.log(laz1.b.toFixed(2)) // "0.67" or something
console.log(laz1.b.toFixed(2)) // "0.67" same
const laz2 = laz1({ c: () => Math.random() < 0.5 });
/* const laz2: LazyLet<{
a: string;
b: number;
c: boolean;
}> */
console.log(laz2.c) // true or false
console.log(laz2.d) // error!
// --> ~
// Property 'd' does not exist on type 'LazyLet<{ a: string; b: number; } & { c: boolean; }>'.
const laz3 = laz2({ b: () => "123", d: () => 456 })
/* const laz2: LazyLet<{
a: string;
c: boolean;
b: string;
d: number;
}> */
console.log(laz3.b) // "123"
console.log(laz3.d) // 456
Solution
First, let's define Merge<T, U>, which is like the intersection T & U except that allows you to overwrite properties from T with same-named properties from U. Here's one way to do it:
type Merge<T, U> =
Omit<T, keyof U> & U extends infer V ? { [K in keyof V]: V[K] } : never;
And you can see how it works:
type Example = Merge<{ a: string, b: number }, { b: string, c: number }>;
/* type Example = {
a: string;
b: string;
c: number;
} */
The b property is string from the second argument to Merge, even though there's a number-valued b property in the first argument. Note that this Merge<T, U> is still only an approximation of what happens when you overwrite T with U; if any of the same-named properties in U are optional then it becomes harder to describe the result. See Typescript, merge object types? for a question and answer which goes into this more deeply. Hopefully for your purposes the one above is sufficient.
Okay, now let's describe the call signature of lazylet():
declare function lazylet<T extends object>(
values: { [K in keyof T]: () => T[K]; }): LazyLet<T>;
where LazyLet<T> is defined as:
type LazyLet<T extends object> = T & (<U extends object>(
values: { [K in keyof U]: () => U[K] }) => LazyLet<Merge<T, U>>);
So a LazyLet<T> can be treated just like an object of type T, as well as a generic function that accepts a values parameter of a mapped type over the generic type parameter U. This values parameter has properties whose keys are the same as those of U and whose values are zero-argument functions that return the corresponding property values of U. And this function returns a LazyLet<Merge<T, U>>. This is a recursive type definition, which allows you to get nested Merged types out.
The lazylet function is essentially just an "empty" LazyLet, equivalent to LazyLet<{}>.
Finally let's give the implementation some typings:
function lazylet<T extends object>(values: { [K in keyof T]: () => T[K] }) {
const createStore = (overrides: any) => {
return lazylet({
...values,
...overrides,
});
};
(Object.entries(values) as Array<[string, () => any]>).map(([key, factory]) => {
Object.defineProperty(createStore, key, {
enumerable: true,
configurable: true,
get() {
const value = factory()
Object.defineProperty(createStore, key, { get: () => value });
return value;
},
})
});
return createStore as LazyLet<T>;
};
I haven't done very much here in the way of type safety. We can assume the implementation is correct (can we? well, I am doing so) and so most of the typing here is just making the compiler ignore any problems it has. Hence overrides is annotated as the any type, the values() methods are asserted to return the any type, and the returned createStore is also asserted to be the desired LazyLet<T> instead of the inferred LazyLet<object>.
Okay, let's make sure it works:
const laz1 = lazylet({ a() { return "hello" }, b() { return Math.random() } })
/* const laz1: LazyLet<{
a: string;
b: number;
}> */
console.log(laz1.a.toUpperCase()) // "HELLO"
console.log(laz1.b.toFixed(2)) // "0.67" or something
console.log(laz1.b.toFixed(2)) // "0.67" same
const laz2 = laz1({ c: () => Math.random() < 0.5 });
/* const laz2:LazyLet<{
a: string;
b: number;
c: boolean;
}> */
console.log(laz2.c) // true or false
const laz3 = laz2({ b: () => "Hello", d: () => 456 })!
/* const laz3: LazyLet<{
a: string;
c: boolean;
b: string;
d: number;
}> */
console.log(laz3.b.toUpperCase()) // "HELLO"
Looks good. The types are what you want them to be.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.