Search code examples
typescriptgenerics

Indexed access for nested type with generic indices seems to generate useless opaque type


I'm defining an RPC-like interface using single huge nested type that names, in order, the namespace, the method name, and the parameters:

interface IndexedActions {
    foo: {
        nested: {
            parameter: string;
        };
    };
    bar: {
        second: {
            argument: number;
        };
    };
}

I want to type-safely invoke these methods, so I define this signature:

function invoke<N extends keyof IndexedActions, M extends keyof IndexedActions[N]>(
    namespace: N,
    method: M, 
    args: IndexedActions[N][M]
);

This works as expected on the caller side, accepting and rejecting all three parameter types appropriately in my test cases and even autocompleting each argument based on the values of the previous arguments.

It does not work in the function's implementation, where args is some type that the compiler won't say much about and somehow isn't assignable to object, even though both the doubly-nested types are object types:

Type 'IndexedActions[N][M]' is not assignable to type 'object'.
  Type 'IndexedActions[N][keyof IndexedActions[N]]' is not assignable to type 'object'.
    Type 'IndexedActions[N][string] | IndexedActions[N][number] | IndexedActions[N][symbol]' is not assignable to type 'object'.
      Type 'IndexedActions[N][string]' is not assignable to type 'object'.

I've tried a number of workarounds, including:

  • intersecting the keyofs with & string (to remove the symbol seen in the error message above)
  • forgoing generics, and instead defining something like invoke(namespace: keyof IndexedActions, method: keyof IndexedActions[typeof namespace], ...)
  • forcing distribution using the T extends T ? ... idiom, which does improve the type of method but does not fix the whole issue
  • mixing-and-matching these approaches

How can I correctly type the implementation?

TypeScript playground link.


Solution

  • TypeScript doesn't try to analyze the IndexedActions interface to conclude the truth of the statement "for all N extends keyof IndexedActions and for all M extends keyof IndexedActions[N], the type Indexedactions[N][M] is assignable to object." It's a true statement, but in order for TypeScript to notice this in general it would either have to brute-force it and check the statement for every possible valid N and M, or it would have to have some higher-order reasoning abilities. The brute force approach does not scale and would seriously affect compiler performance, while the higher-order reasoning approach would be difficult or impossible to implement. So you get an error.


    The easiest way to resolve the error is to just assert your way out of it. You know that args is assignable to object, so you can just assert that:

    function invoke<
        N extends keyof IndexedActions,
        M extends keyof IndexedActions[N]
    >(
        ns: N,
        method: M,
        args: IndexedActions[N][M]
    ) {
        const o = args as object;
    }
    

    Similarly, you can replace references to the type IndexedActions[N][M] with references to an equivalent type which is easily seen to be assignable to object. If you've got a type T and you know it's assignable to U but the compiler doesn't, you can often replace references to T with references to the intersection T & U or Extract<T, U> using the Extract utility type:

    function invoke<
        N extends keyof IndexedActions,
        M extends keyof IndexedActions[N]
    >(
        ns: N,
        method: M,
        args: IndexedActions[N][M] & object
    ) {
    
        const o: object = args;
    }
    

    or

    function invoke<
        N extends keyof IndexedActions,
        M extends keyof IndexedActions[N]
    >(
        ns: N,
        method: M,
        args: Extract<IndexedActions[N][M], object>
    ) {
    
        const o: object = args;
    }
    

    In both of those cases, the behavior when calling invoke() doesn't change much (except for possibly how IntelliSense displays the type). The restriction to object is automatically satisfied for every valid call.


    Finally, if you really wanted TypeScript to see a higher-order relationship like this, you would need to refactor your types so that they are explicitly written in such terms. You can do this with mapped types, as described in microsoft/TypeScript#47109:

    type IndexedActionsMap = {
        [M in keyof IndexedActions]: {
            [N in keyof IndexedActions[M]]: (
                IndexedActions[M][N] & object 
                // or Extract<IndexedActions[M][N], object>
            )
        }
    }
    
    function invoke<
        N extends keyof IndexedActionsMap,
        M extends keyof IndexedActionsMap[N]
    >(
        ns: N,
        method: M,
        args: IndexedActionsMap[N][M]
    ) {
        const o: object = args;
    }
    

    Functionally this is the same as the previous approach, but the difference is that now the type IndexedActionsMap[N][M] is automatically seen by TypeScript as assignable to object, even though that type does not directly mention object. Because IndexedActionsMap is a (nested) mapped type that encodes the intended relationship directly into the type, TypeScript can see it.

    This is likely overkill for this example, especially because you're not really trying to do much with args. But there are often use cases where the compiler-verified type safety is worth the complexity.


    Playground link to code