Issue
So we have like:
Dict
which is{ [key: string]: () => any }
X
which is the return I want
And I'm trying to create a type for a function that:
- receives a dictionary
Dict
T
- returns an
X
Now, X
is also a function, but this one:
- receives a
Dict
U
- returns itself (
X
) - we can access all the properties of
U
plus all the properties ofT
that 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 Merge
d 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.