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
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.
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.
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:
S
in one place to match the S
in another place.const composeBundles = <B extends Bundle<any, any, any>[]>(...bundles: B): BundleComposition<B> => {
StateFromReducersCollection
and ReducerFromReducerCollection
.@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.