Search code examples
typescriptreduxtypescript-generics

How to properly type a structure that is amorphous but has a general shape, in order to avoid losing typeinfo and errors?


I am trying to define types as collections of shaped types using generics but am either doing something wrong or TS cannot do it. I've tried a lot of things in the past week but most of it is "lost" due to trying other things over and over. I am not sure if its possible, but my guess is, it should be. I will try to simply this as much as possible, but it will be a longer post, sorry no TLDR for this one.

The amount of types needed to produce a minimal-viable-reproducible-example for this particular issue is like 200 lines of types-only code, most of which are irrelevant but because they all chain one into another, its hard to extract a simple example from them, thus I will explain the issue at hand and post a link to a typescript playground with the code in case someone needs to take a look.

For context, I am developing some form of Redux Extension, or Redux2.0 if you will.

I am trying to define a type for a "return value" of a function which takes in an "array" of Bundles and returns a result which is based on those bundles. What is a bundle you ask? Its sort of a "Redux Plugin", something like this:

interface Bundle<
    S = any,
    Args extends object = object,
    ActionExt extends object = object
  > {
    name: string
    reducer?: Reducer<S>
    selectors?: { [key: string]: Selector }
    reactors?: { [key: string]: Reactor }
    actions?: { [key: string]: AnyAction | ThunkAction | ActionExt | ?PossibleFutureProblem? }
    priority?: number
    init?: (store: Store) => void
    args?: ArgGenerator<Args>
    middleware?: MiddlewareGenerator<ActionExt>
    persist?: string[]
  }

So once the function processes multiples of these bundles, it is suppose to return a BundleComposition, that looks something like this:

interface BundleComposition {
  bundleNames: string[]
  reducers: { [key: string]: Reducer }
  selectors: { [key: string]: Selector }
  reactors: { [key: string]: Reactor }
  actions: { [key: string]: AnyAction }
  initMethods: Array<(store: Store) => void>
  args: Array<{ [I in keyof any[]]: ArgGenerator<any> }[number]>
  middleware: MiddlewareGenerator[]
  processed: Bundle[]
}

Problem I am having is, well twofold, so lets tackle them one by one

1. The Error Issue with generics/default values

When defining this function, we'd define it a function that takes in multiple Bundles and returns a BundleComposition, thus something like this would work:

type ComposeBundles = (...bundles: Bundle[]) => BundleComposition

Note that when defining this function, it is impossible to define what "shape" each of these bundles is, precisely, we know they must be a bundle, but Bundle type can, and most definitively should/will have it's type-arguments defined when creating it, however this function is used on multiple different bundles and thus we cannot define the shape of this "array" it accepts, because they are both unknown, and not the exact same shape.

Now, when we define a bundle, like such:

interface ICFG {
    tag: 'testconfig'
}

interface IActExt {
    specificTag: number
}

const INITIAL_STATE = {
    testState: 0,
}

// a simple typeguard
const isSpecificAction = (action: any): action is IActExt => !!action.specificTag

const ExampleBundle: Bundle<typeof INITIAL_STATE, { testarg: 'success' }, IActExt> = {
    name: 'testbundle',
    actions: {
        testAction: async (a, b) => { },
    },
    init: store => {
        console.log('initializing store')
        console.log(store)
    },
    args: store => {
        console.log('passing in extra args')
        // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
        return {
            testarg: 'success',
        }
    },
    middleware: composition => store => next => action => {
        console.log('triggered middleware for action: ', action)
        if (isSpecificAction(action)) console.log(action.specificTag)
        else next(action)
    },
    reducer: (state = INITIAL_STATE, { type }) => {
        if (type === '@CORE/INIT')
            return {
                ...state,
                testState: state.testState + 1,
            }
        return state
    },
}

This is a valid bundle, there is no errors thrown by the TSC, it's generics are well defined, but it is impossible to use this bundle as argument of the previously mentioned function, when you try to do the following, an error occurs:

composeBundles(ExampleBundle)

Error Message:

Argument of type 'Bundle<{ testState: number; }, { testarg: "success"; }, IActExt>' is not assignable to parameter of type 'Bundle<any, object, object>'.
  Types of property 'middleware' are incompatible.
    Type 'MiddlewareGenerator<IActExt> | undefined' is not assignable to type 'MiddlewareGenerator<object> | undefined'.
      Type 'MiddlewareGenerator<IActExt>' is not assignable to type 'MiddlewareGenerator<object>'.
      Type 'object' is not assignable to type 'IActExt'.(2345)

And this error confuses me, because if you pay close attention, I am attempting to pass a VERY DEFINED BUNDLE into a function that expects a matching, albeit slightly different SHAPE as an argument, yet the error is saying I am doing the opposite. I read that object is not assignable to type IActExt where I've never assigned that, I did assign it the other way around no? What am I missing here? If a function expects a Bundle with a generic value equating object and you pass a Bundle with a generic of T where T extends object is that not suppose to work? The T is an extension of an object by my logic and everything I know about the whole SOLID/OOP shenanigans, this should work.

2. The whole "array" is not "really an array" problem

Truth be told, what we are dealing with in the function mentioned in issue 1 is not an "array", per say. It is as we can see a spread ("...") of multiple arguments, each of which is defined as a specific Bundle and the order of which is very well known because we are calling a function with arguments in a specific order, thus, we are dealing with a Tuple not an Array, but there is no way to define it as such because we don't know what the arguments will be once the function is invoked, nor how many will we have.

Essentially the issue is, we have defined the types:

type T<G extends object = object> = G // for simplicity, its obviously more then this

type myObjectWrapper = {
   subObjects: T[]
}

type myFunction = (...args: T[]): myObjectWrapper 

type T1 = T<{a: string}>
type T2 = T<{b: string}>

And then we implement the "myFunction" and expect to get the Result to be related to the input values of arguments, and the type-system should be aware of this, maybe not inside the body of the function (implementation), but certainly should be aware of it as a result of invocation.

const example: myFunction = (...args) => {
  // ...implementation stuff
  return { subObjects: args }
}

const a: T1 = { a: 'some string' }
const b: T2 = { b: 'some other string' }

const myResult = example(a, b) // doesn't work properly, type information is lost

So what is a proper pattern for defining these functions that accept an "array" of values, be it as an argument spread or an array if that makes it better somehow, where each value must be of some type T<G> but the types of G are different. This function returns an object wrapped around the values taken. How do we write this properly?

Because I find using a simple T[] doesn't work, yet I cannot specify a G because that could be anything that extends an object, which also forces me to define a "default" for the value G so I just default to object, but then I get errors from "issue 1" above.


Solution

  • I'm going to start by talking about the examples in your 2. The whole "array" is not "really an array" problem section.

    type T<G extends object = object> = G // for simplicity, its obviously more then this
    
    type myObjectWrapper = {
       subObjects: T[]
    }
    

    The myObjectWrapper type must be a generic type. Otherwise, subObjects will have type T<object>[] always, in all circumstances, regardless of the arguments of myFunction.

    @Filly is right that you have far too many default values on your generics. But you're not properly understanding how inference works.

    The way I see it is, without default values, TS forces me to define the generic whenever I use a type, it doesn't infer it, this is why I make defaults so that I can write types like T WITHOUT the next to it. Because when I define types as T then whenever I use them, it asks me to also supply a generic.

    There is nothing which can be inferred in type myObjectWrapper because there are no generics. It is always { subObjects: T<object>[] }.

    The way to make use of type inference is to define strongly-typed functions, where the generic type parameters (T and G) are passed between the various arguments and return types of the function. The inference comes into play when you go to use the function. You will not have to specify any generics when calling the function because they can be inferred from the arguments.

    T is a higher-order type that modifies another type G. Therefore myFunction needs to know what G it is modifying. I think understood that a little bit in your linked playground, where you made myFunction a generic which depends on G.

    type myFunction = <G extends T[]>(...args: G) => myObjectWrapper
    

    But you've got some fundamental contradictions, as G extends T[] means that G is an array of T objects but type T<G extends object = object> = G means that that T equals G. That's impossible.

    So let's lay out some assumptions: I'll say that myFunction is a generic which depends on type G. Its argument is an array whose elements have varying types and the generic type parameter G describes the type of this tuple. The function returns a wrapped object where the subObjects is an array containing a modified version of each of your input objects.

    TypeScript mapped types can applied to tuples and arrays since v3.1. We need to use a mapped type to describe the transformation of ...args: G to subObjects: T<G>.

    When I define the types, I do not have default values anywhere. Every generic needs to be passed around in the implementation.

    // Mapped type to transform the tuple.
    type T<G> = {
        [K in keyof G]: G[K] & { added: string }
    }
    
    // Return type of the function.
    interface myObjectWrapper<G> {
      subObjects: T<G>
    }
    
    // The function depends on the type of its input tuple G.
    type myFunction = <G extends object[]>(...args: G) => myObjectWrapper<G>
    
    const example: myFunction = (...args) => {
      // We need an assertion because array.map doesn't understand that its a tuple.
      return { subObjects: args.map(g => ({ ...g, added: 'added' })) as any }
    }
    

    But you don't need any types when you use the function. Your types T1 and T2 can go. All we do is call the function with variables a and b.

    const a = { a: 'some string' }
    const b = { b: 'some other string' }
    
    const myResult = example(a, b)
    

    This is where type inference comes into play. Our function is well-typed so the myResult variable gets the correct inferred type:

    const myResult: myObjectWrapper<[{ a: string; }, { b: string; }]>
    

    Which means that each subObject has the correct, very specific type.

    // No errors.
    console.log(myResult.subObjects[0].a)
    console.log(myResult.subObjects[1].b)
    
    // Errors, as expected, when accesing types on the wrong array element.
    console.log(myResult.subObjects[0].b)
    console.log(myResult.subObjects[1].a)
    

    I've already written a novel and I haven't gotten into your Redux types. But hopefully I've explained some of the fundamentals. Here's some general advice:

    • Remove all default values from your types to force yourself to pass around the known generics. You need the state type S in one place to match the S in another place.
    • In some places you can use a single generic to refer to a vary complex type which itself contains a bunch of generics. Nothing is lost here, as the new type represents the entirety of the complex type. For example:
    const composeBundles = <B extends Bundle<any, any, any>[]>(...bundles: B): BundleComposition<B> => {
    
    • When using this sort of logic, you can define types to work backwards to extract internal types from a complex type. You are already doing this with StateFromReducersCollection and ReducerFromReducerCollection.
    • Try using keyed dictionary objects instead of tuples. I find it a lot easier to keep the correct types and to know what you are accessing.
    • Look at the source code and the types for the @reduxjs/toolkit package as they've already dealt with a lot of the issues that you are dealing with here. Their createSlice function creates a "slice" object which is similar to your "bundle". It contains a strongly-typed reducer and a dictionary of strongly-typed action creator functions.
    • Read Lenz's article Do not create union types with Redux Action Types. It's most likely an antipattern. which explains why Redux Toolkit instead uses type guard functions to associate the payload type with an action.