简体   繁体   中英

How to debug recursive TypeScript generic type

Given the following schema and instance:

interface SubState {
    value: number
}

interface AppState {
    subState: SubState
}

const state: AppState = {
    subState: { value: 42 },
}

I have an nested object with functions (Redux selectors) that use that state instance or sub parts of it:

const selectors = {
    subState: { isPositive: (state: SubState) => state.value > 0 },
}

The actual selector object is several layers deep and many tens of functions at different levels of the state tree.

I have transformed the selectors object into the following type (using a function shown at the end). Every key is iterated over, if it is a function then it is replaced with a function that takes the top level state AppState , finds the correct substate for the function and invokes it with it. So the signatures are all transformed from: (SpecificSubState) => ReturnType to (AppState) => ReturnType :

const mappedSelectors = {
    subState: { isPositive: (state: AppState) => true },
}

I would like to have a working robust dynamic type for the return value of the mapping function. I attempted using the following implementation but it does not work yet:

interface TypedFunction<T> extends Function {
    (state: T): any;
}
type RecursivePicker<Sel, State> = { 
    [K in keyof Sel]: Sel[K] extends TypedFunction<State>
        ? ((state: AppState) => ReturnType<Sel[K]>)
        : (
            Sel[K] extends object
            ? RecursivePicker<Sel[K], State>
            : never
            // never
        )
}

const mappedSelectors: RecursivePicker<typeof selectors, AppState> = {
    subState: { isPositive: (state: AppState) => true },
}

// errors with:
//   Cannot invoke an expression whose type lacks a call signature.
//   Type 'RecursivePicker<(state: SubState) => boolean, AppState>' has no compatible call signatures.
mappedSelectors.subState.isPositive(state)

type ExpectedTypeManual = (state: AppState) => true
type MutuallyExtends<T extends U, U extends V, V=T> = true
// errors with:
//   Type 'RecursivePicker<(state: SubState) => boolean, AppState>' does not satisfy the constraint 'ExpectedTypeManual'.
//   Type 'RecursivePicker<(state: SubState) => boolean, AppState>' provides no match for the signature '(state: AppState): true'.
type ShouldBeNoErrorHere = MutuallyExtends<typeof mappedSelectors.subState.isPositive, ExpectedTypeManual>

I'm not sure where the problem lies. Any advice on how to debug further please?

Link to Typescript playground with code .

Related questions:

Mapping function included for completeness (function works but typing is partial and not working yet):

function mapSelectors<Sel, State, SubState> (selectors: Sel, stateGetter: (state: State) => SubState) {

  const mappedSelectors = Object.keys(selectors).reduce((innerMappedSelectors, selectorOrSelectorGroupName) => {

    const value = selectors[selectorOrSelectorGroupName]
    if (typeof value === "function") {
      innerMappedSelectors[selectorOrSelectorGroupName] = (state: State) => value(stateGetter(state))
    } else {
      function getSubState (state: State) {
        return stateGetter(state)[selectorOrSelectorGroupName]
      }
      innerMappedSelectors[selectorOrSelectorGroupName] = mapSelectors(value, getSubState)
    }

    return innerMappedSelectors
  }, {} as {[selectorName in keyof typeof selectors]: (state: State) => ReturnType<typeof selectors[selectorName]>})
  // }, {} as {[selectorName in keyof typeof gameSelectors]: (state: ApplicationState) => ReturnType<typeof gameSelectors[selectorName]>}),

  return mappedSelectors
}

So, the type of mappedSelectors displays unhelpfully as

const mappedSelectors: RecursivePicker<{
    subState: {
        isPositive: (state: SubState) => boolean;
    };
}, AppState>

One technique I've been using when I have a complicated type whose IntelliSense information is opaque and full of other type names is to convince the compiler to expand out the property names with the following type alias:

type Prettify<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;

That doesn't really change the type much (there might be edge cases, but it mostly just forces the compiler to walk through the properties of the object and write them out) but it usually improves the readability of the type.

Let's modify RecursivePicker to use it:

type RecursivePicker<Sel, State> = Prettify<
  {
    [K in keyof Sel]: Sel[K] extends TypedFunction<State>
      ? ((state: AppState) => ReturnType<Sel[K]>)
      : (Sel[K] extends object ? RecursivePicker<Sel[K], State> : never)
        // never
  }
>;

Now we look at mappedSelectors and see:

const mappedSelectors: {
    subState: {
        isPositive: {};
    };
}

Now that is a transparent type, and transparently not what you want. Why not? Let's clear away all the other stuff and see what happens with isPositive :

type IsPositive = RecursivePicker<
  { isPositive: (x: SubState) => boolean },
  AppState
>;
// type IsPositive = { isPositive: {}; }

Sure enough, it becomes { isPositive: {} } . That means the (x: SubState) => boolean property of doesn't extend TypedFunction<State> where State is AppState . That is, (x: Substate) => boolean doesn't extend (x: AppState) => any . Maybe you wanted, instead of RecursivePicker<..., AppState> , to use RecursivePicker<..., SubState> ?

const mappedSelectors: RecursivePicker<typeof selectors, SubState> = {
  subState: { isPositive: (state: AppState) => true }
};
// const mappedSelectors: {
//    subState: {
//        isPositive: (state: AppState) => boolean;
//    };
// }

Now that looks a lot like the type you wanted, and you can invoke it:

mappedSelectors.subState.isPositive(state); // okay

Your expected type is still not exactly right, because the compiler sees the return value as boolean and not true . As long as boolean is all right, the following works:

type ExpectedTypeManual = (state: AppState) => boolean;
type MutuallyExtends<T extends U, U extends V, V = T> = true;
type ShouldBeNoErrorHere = MutuallyExtends<
  typeof mappedSelectors.subState.isPositive,
  ExpectedTypeManual
>; // okay

That's probably your main issue.


So why did you get {} before? If a property is a function that doesn't match the type you expected, it goes on to the next clause, which is Sel[K] extends object ? ... Sel[K] extends object ? ... . But functions do extend object (they are not primitives):

type FunctionExtendsObject = (() => boolean) extends object ? true : false; // true

So the (state: SubState) => boolean gets mapped over, and it doesn't have any mappable properties (it does have properties but TypeScript ignores them when you examine the type)

type NoMappableKeys = keyof (() => boolean); // never

So you end up with {} coming out.

What do you want to do when an object has a function property that does not match your intended type? Should it be unmodified? Should the property be removed? Should you get a compile error? Should it be changed to something completely different? You need to decide that and then possibly modify RecursivePicker . But I think the main question has been answered, so I'll stop there.

Hope that helps; good luck!

Link to code

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM