1

The Setup

I have a lib that provides helper functions to dynamically grow and use an object, and an arbitrary set of files that use this helper to define a list of steps this object will go through:

// samplestepdefinition.ts
interface C {
  c1: string,
  c2: string
}

const isGreaterThanTwo = (number: number): boolean => number > 2

const steps = [
  Steps.initialize('a', 3),
  Steps.provide('b', 'a', isGreaterThanTwo),
  Steps.provide('c', 'b', (b: boolean): C => ({ c1: 'foo', c2: b ? 'bar' : 'baz' })),
  Steps.use('c.c2', console.log)
]
Steps.processSteps(steps) // logs 'bar'
// stepslib-simplified.ts
import lodash_get from 'lodash/fp/get'
import lodash_set from 'lodash/fp/set'

interface StepContext extends Object { }
type StepResolver = (context: StepContext) => StepContext // but the output is modified


type ProviderFn = (valueToUse: any) => any
type Provide = (keyToSet: string, keyToUse: string, fn: ProviderFn) => StepResolver
const provide: Provide = (keyToSet, keyToUse, fn) => (context) =>
  lodash_set(keyToSet, fn(lodash_get(keyToUse, context)), context)

type Initialize = (keyToSet: string, value: any) => StepResolver
const initialize: Initialize = lodash_set

type UserFn = (valueToUse: any) => any
type Use = (keyToUse: string, fn: UserFn) => StepResolver
const use: Use = (keyToUse, fn) => context => fn(lodash_get(keyToUse, context))

const Steps = {
  provide,
  initialize,
  use,
  processSteps: (steps: StepResolver[]): void => {
    steps.reduce((prevContext, step) => step(prevContext), {})
  }
}
export default Steps

The Challenge

In this example, there is no actual type validation. If the type definition of isGreaterThanTwo would take a string as argument, the code would not clash. Neither would replacing step 3 with Steps.provide('c', 'b', (b: number): [...].

Best Case

I wonder if it wouldn't be possible to just infer all of these types by just changing the stepslib to track this dynamically growing object. ReturnType can be inferred from ProviderFn and hopefully StepResolver can return a compound type of whatever it got plus key+value from Provider.

I tried around a bit but couldn't figure out how this works.

Alternative

If that's not possible I could imagine having the stepdefinition file provide something like

interface SpecificContext extends StepContext {
  a?: number
  b?: boolean
  c?: C
}

as a generic to processSteps to at least use that for type validation.

Here's what I tried:

// stepslib-simplified.ts
import lodash_set from 'lodash/fp/set'

interface StepContext extends Object { }
type StepResolver = <Context extends StepContext>(context: Context) => Context // <- changed

// [...]

const Steps = {
  // [...]
  processSteps: <Context extends StepContext>(steps: StepResolver[]): void => { // <- changed
    steps.reduce((prevContext, step) => step<Context>(prevContext), {})
    // ^ Error: Argument of type '{}' is not assignable to parameter of type 'Context'.
    // '{}' is assignable to the constraint of type 'Context', but 'Context' could be instantiated with a different subtype of constraint 'StepContext'.
  }
}

Any ideas how to solve that?

NOTE: I admit step 4 using 'c.c2' is more advanced. I'd be glad to at least get a solution for providing a direct keys without dot-nesting. I found out there is a way to evaluate strings like <'${path}.${infer rest}'> but don't yet know how that would be used there.

EDIT: Stackblitz Code Editor link of this code

KonstantinK
  • 757
  • 1
  • 8
  • 23

1 Answers1

1

Maybe something like this will do?

(I've changed the structure a little just to get the basic idea how it works)

interface Step<I, O> {
  keyToUse: string ;
  keyToSet?: string;
  fn: (input: I) => O
}

class Steps<D extends Record<string, any>, T extends D> {
    private constructor(public readonly defaultContext: D, private steps: Step<any, any>[] = []) {

    }

    public static initialize<K extends string, T extends {}>(keyToSet: K, value: T) {
        return new Steps({ [keyToSet]: value } as Record<K,T>);
    }

    public provide<A extends keyof T, B extends string, C extends {}>(keyToSet: B, keyToUse: A, fn: (inputVar: T[A]) => C): Steps<D, T & Record<B, C>> {
        return new Steps(
          this.defaultContext,
          [...this.steps, {
            keyToUse: keyToUse as string,
            keyToSet: keyToSet,
            fn: fn
          }]
        );
    }

    public use<A extends keyof T>(keyToUse: A, fn: (inputVar: T[A]) => void): Steps<D,T> {
        return new Steps(
          this.defaultContext,
          [...this.steps, {
            keyToUse: keyToUse as string,
            fn: fn
          }]
        );
    }

    public process() {
      return this.steps.reduce((context, step) => {
        if (step.keyToSet) {
          return {...context, [step.keyToSet]: step.fn(context[step.keyToUse])}
        } else {
          step.fn(context[step.keyToUse]);
          return context;
        }
      }, this.defaultContext) as T;
    }
}

const steps =  Steps.initialize('a', 1)
    .provide('b', 'a', (val) => val > 0)
    .provide('c', 'b', (val) => ({objA: val}));

console.log(steps.defaultContext)
console.log(steps.process())

I have changed Steps to builder object, because we need to track our type T of the created object (aka. your SpecificContext). With array there is no way to track the type of the context.

Edit: accessing nested properties

Thanks to this anwser by jcalz the code can be enhanced to work with nested properties (I used the code from the anwser, so all the credit goes to jcalz).

Here is the Flatten type code by jcalz:

type Flatten<T, O = never> = Writable<Cleanup<T>, O> extends infer U ?
    U extends O ? U : U extends object ?
    ValueOf<{ [K in keyof U]-?: (x: PrefixKeys<Flatten<U[K], O>, K, O>) => void }>
    | ((x: U) => void) extends (x: infer I) => void ?
    { [K in keyof I]: I[K] } : never : U : never;

type Writable<T, O> = T extends O ? T : {
    [P in keyof T as IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P>]: T[P]
}

type IfEquals<X, Y, A = X, B = never> =
    (<T>() => T extends X ? 1 : 2) extends
    (<T>() => T extends Y ? 1 : 2) ? A : B;

type Cleanup<T> =
    0 extends (1 & T) ? unknown :
    T extends readonly any[] ?
    (Exclude<keyof T, keyof any[]> extends never ?
        { [k: `${number}`]: T[number] } : Omit<T, keyof any[]>) : T;

type PrefixKeys<V, K extends PropertyKey, O> =
    V extends O ? { [P in K]: V } : V extends object ?
    { [P in keyof V as
        `${Extract<K, string | number>}.${Extract<P, string | number>}`]: V[P] } :
    { [P in K]: V };

type ValueOf<T> = T[keyof T]

function lookup<T, K extends keyof Flatten<T>>(obj: T, key: K): Flatten<T>[K];
function lookup(obj: any, key: string) {
    const i = key.indexOf(".");
    return (i < 0) ? obj[key] : (lookup as any)(obj[key.substring(0, i)], key.substring(i + 1));
}

And here is your code with Flatten:

interface Step<I, O> {
  keyToUse: string ;
  keyToSet?: string;
  fn: (input: I) => O
}

class Steps<D extends Record<string, any>, T extends D> {
    private constructor(public readonly defaultContext: D, private steps: Step<any, any>[] = []) {

    }

    public static initialize<K extends string, T extends {}>(keyToSet: K, value: T) {
        return new Steps({ [keyToSet]: value } as Record<K,T>);
    }

    public provide<A extends keyof Flatten<T, Date>, B extends string, C extends {}>(keyToSet: B, keyToUse: A, fn: (inputVar: Flatten<T, Date>[A]) => C): Steps<D, T & Record<B, C>> {
        return new Steps(
          this.defaultContext,
          [...this.steps, {
            keyToUse: keyToUse as string,
            keyToSet: keyToSet,
            fn: fn
          }]
        );
    }

    public use<A extends keyof Flatten<T, Date>>(keyToUse: A, fn: (inputVar: Flatten<T, Date>[A]) => void): Steps<D,T> {
        return new Steps(
          this.defaultContext,
          [...this.steps, {
            keyToUse: keyToUse as string,
            fn: fn
          }]
        );
    }

    public process() {
      return this.steps.reduce((context, step) => {
        if (step.keyToSet) {
          return {...context, [step.keyToSet]: step.fn(lookup(context, step.keyToUse as keyof Flatten<typeof context>))}
        } else {
          step.fn(lookup(context, step.keyToUse as keyof Flatten<typeof context>));
          return context;
        }
      }, this.defaultContext) as T;
    }
}



interface C {
  c1: string,
  c2: string
}

const isGreaterThanTwo = (number: number): boolean => number > 2

Steps.initialize('a', 3)
  .provide('b', 'a', isGreaterThanTwo)
  .provide('c', 'b', (b: boolean): C => ({ c1: 'foo', c2: b ? 'bar' : 'baz' }))
  .use('c.c2', console.log)
  .process();

You can find the complete code in sandbox here.

bobkorinek
  • 646
  • 3
  • 16
  • Interesting, thank you bob. So one way to get it to work is to have a wrapping class keep track of the context. There is one key difference though between this code and what I was doing above, and that is the fact that I was able to define a list of steps that can be processed at a later point in time, whereas this just executes the changes immediately. So unfortunately I can't use something like this as is. But having an example of how this works with `>` gives me a good basis to fiddle around with further. – KonstantinK Sep 13 '22 at 05:31
  • 1
    Glad to help. Yes, you keep the context in your `Steps` class, which acts like builder. Every time you add your *step*, it will return new (or the same) instance of `Steps` which holds the new context. I can't think of solution which would work for the array method, because every *step* inside the array must somehow know about the previous *steps*. – bobkorinek Sep 13 '22 at 06:27
  • I have changed the example solution. You see you can do the same thing even if you don't process it immediately. – bobkorinek Sep 13 '22 at 06:52
  • This is awesome, thanks a lot! One note for anyone looking over this code and, like me, scratching their heads why `Flatten` takes a date of all things - it's just a remnant of the original question the Flatten code was written for and can just be simplified to `Flatten`. – KonstantinK Sep 14 '22 at 06:54