2

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:

  1. receives a dictionary Dict T
  2. returns an X

Now, X is also a function, but this one:

  1. receives a Dict U
  2. returns itself (X)
  3. we can access all the properties of U plus all the properties of T that aren't overwritten by properties of U
  4. 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
vhoyer
  • 764
  • 8
  • 16
  • 1
    Does [this approach](https://tsplay.dev/mpLvMm) meet your needs? I'm not 100% sure what your requirements are around typing, so if that doesn't work for you please let me know. If it does work I can write up an answer explaining. – jcalz Aug 18 '22 at 17:55
  • What you want to achieve is something like a type building pattern, where the type gets updated, everytime something is added? If so a friend of mine build a dependencycontainer, that is working like this. [here](https://github.com/PhilippDehler/ts-utils/blob/main/src/utils/DependencyContainer.ts). If you are looking for something like this, will provide a full explanation of how it works – Filly Aug 19 '22 at 11:05
  • hey, @jcalz, that's almost exactly what I was looking for. The `b has already been used so we can't re-use it` feature is actually a bug, as I wanted to provide the ability for further calls to override values already defined earlier, that said, I would appreciate both explanations with the `never`, and without, thanks – vhoyer Sep 10 '22 at 17:08
  • @Filly, that's also similar to what I was going for, although, when I would "add" a value to the DepContainer, I would want to add it in "bulk", thus creating a kind of "new layer" on top of the original layer from the "lazylet" – vhoyer Sep 10 '22 at 17:13
  • At the risk of taking another three weeks until you reply, I'm going to ask another question... would you prefer [this version](https://tsplay.dev/w8Lydw) where the resulting type is not `T & U` but something like `Omit & U`? If so I'll write it up but I'd prefer you [edit] the question to say that's what you want and not write things like `{b: string} & {b: number}`. Let me know (preferably sooner rather than later) – jcalz Sep 10 '22 at 19:32
  • boy, I'm not sure how to ask for that, but yes, I would prefer the Omit version, I did edit the question, is it appropriately asking for it now? (btw, sorry for the late reply, I was vacationing, didn't touch the computer for those 3 weeks) – vhoyer Sep 10 '22 at 19:55
  • 1
    In your question you still say "we can access the properties of both `T` and `U` `(T & U)`" but that's not exactly what you want. You want all the properties of `U` plus all the properties of `T` that aren't *overwritten* by properties of `U`. Anyway I will write up an answer when I get a chance. – jcalz Sep 10 '22 at 20:27

1 Answers1

2

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.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360