Issue
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
Solution
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.
Answered By - bobkorinek
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.