Rematch Code Deep Dive: Part 6 - The Rematch type system

This is the final article of the Rematch Code Deep Dive Series, focusing on Rematch’s type system.
This final part of the series discusses the type system behind Rematch, which was my main contribution to the Rematch team. While refactoring it, I encountered numerous problems: some were resolved, some required “unique” design decisions due to trade-offs, some were limitations of the TS language, and others remain unsolved. In this article, I will discuss these issues for further exploration.
Due to the extensive related code, not all of it is included below. Click here to view all the code.
1 Core Types
1.1 Model
In Rematch, a key concept is the Model. Before diving deep into the Rematch type system, we need to understand this concept. Here is its definition:
| |
We are mainly concerned with state, reducers, and effects. State is straightforward, so let’s look at the latter two.
1.2 ModelReducers
| |
In ModelReducers, it’s important to note that the first parameter of the Reducer function is state.
1.3 ModelEffects and ModelEffectsCreator
Effects support two types: plain object-defined ModelEffects and function ModelEffectsCreator. Let’s start with the first one:
ModelEffects
| |
ModelEffects and ModelReducers are similar, but the former receives the generic type TModels, which contains information about all Models. We use the RematchRootState type to extract the global rootState as the second parameter of ModelEffect. The latter only requires the corresponding model state information. We will discuss RematchRootState below.
Moreover, ModelEffect binds the context’s this to dispatch[currentModel], so actions can be dispatched using this[reducerName | effectName]. However, there are issues with type inference here, which I will address in the final problem summary section.
ModelEffectsCreator
| |
Besides the ModelEffects method, effects can also be defined as a function taking dispatch as a parameter and returning ModelEffects. This allows to dispatch actions of the current model using the context this in effects, as well as dispatching actions of all models using dispatch. We will discuss RematchDispatch later.
1.4 RematchRootState
Those familiar with Redux know it has two core parts: the action-dispatching dispatch and the global RootState. When studying Rematch Model earlier, we encountered two types, RematchRootState and RematchDispatch. Let’s discuss them in detail.
First, let’s look at the type definition of RematchRootState:
| |
Simply put, it obtains each Model’s state, combining them with modelName as the key. Models include both user-defined models and those exported by used plugins (e.g., using the loading plugin exports the loading Model).
1.4.1 About Generics type TModels and TExtraModels
Attentive readers may have noticed that many types above use two generic parameters, TModels and TExtraModels, extensively used in Rematch’s type system. TModels is mandatory, representing user-defined Models, while TExtraModel is optional, used if the user utilizes plugins exporting Models.
Initially, I set default values for both generic parameters as {}, but {} does not mean an empty type; it represents any non-empty value, so I changed it to Record<string, any>. However, this was also type-unsafe.
Eventually, considering TModels is mandatory (since users of Rematch will definitely define Models), I removed the default value for TModels and changed the default value for TExtraModel to Record<string, never>. Using Record<string, unknown> is also type-safe, but it does not satisfy the extends Models<TModels> constraint (since unknown cannot be assigned to Model), so I switched to never.
1.5 RematchDispatch, the hybrid ReduxDispatch
Next, let’s look at RematchDispatch:
| |
Firstly, RematchDispatch is the core of Rematch, with complex type inference, so I’ve simplified it here. First, Rematch’s dispatch is a hybrid type based on Redux dispatch. Hence the use of ReduxDispatch & .....
Secondly, it needs to extract corresponding actions from effects and reducers. Since the reducer’s first parameter is model state, TModel['state'] information is passed in.
Lastly, reducerActions and effectActions are combined using MergeExclusive. Initially, Rematch directly used the & operator to combine, but as each action in Rematch carries information like { isEffect: boolean }, if a reducer and effect share the same name, a type incompatibility issue arises (since no type can simultaneously be compatible with { isEffect: true } and { isEffect: false }). Here’s a simple example:
| |
In truth, even if an effect and reducer have the same name, Rematch’s code supports it. I will simply discuss the type compatibility issue here and continue the discussion with everyone later.
2 createModel helper function
Besides the core types, I also designed a utility function createModel in Rematch. This function doesn’t have any practical effect and is only used to perfect types, reducing the need for users to manually add them. Here is the related code:
| |
The function parameter is empty, and the return value is also a function, whose parameter is a Model object and returns the Model object itself, keeping the Model’s attribute types unchanged. The main functions are as follows:
- By defining the type of
state, the type of the first parameter in reducers is passed through, avoiding repetitive definitions. - By passing in the
RootModelgeneric parameter, the type of the first parameterdispatchin effects is automatically inferred. - By passing in the
RootModelgeneric parameter, the type of the second parameterrootStatein individual effects is automatically inferred.
Initially, ModelCreator was like this:
| |
Although much simpler, the above could not meet the first point as the state type was not connected. Then I changed it to:
| |
However, the above two approaches fail to ensure that the return type is inferred as the user-defined Model type. In the first approach, the return type M is directly inferred as Model<RootModel, ModelState>. In the second approach, although the attributes are listed, the type of an individual attribute like reducers is just ModelReducers<ModelState>. This means that the specific individual reducer type, like SET_PLAYERS in the above example, is not deduced, and the contextual type is lost.
That’s why I eventually adopted a fully expanded approach, which made all functionalities achievable.
Although this method works, at first glance, one might wonder why two functions were used.
The reason is that TypeScript currently does not support partial type parameter inference. This means that if a function has multiple generic parameters, when calling this function, either all parameters must be provided by the user, or they are all inferred by TS automatically.
Therefore, I devised a two-function approach: the first function allows the user to pass specific generic parameters, while the second function does not require the user to specify anything, leaving it to TS for automatic inference. Interestingly, I was not aware of “partial type parameter inference” at the beginning of the design and did not know that this “double function” design could help solve this problem. I only discovered this PR later, and someone even specifically mentioned this design in the replies.
Finally, one might ask why I didn’t just define a single function type, but instead used the ModelCreator interface definition. This was to support function overloading in different modules. By representing the function with an interface, Module Augmentation can be utilized, which I will discuss separately later.
3 Circularly Reference
When I previously mentioned the Model type, did you notice a detail?
| |
Take a close look at the generic parameter TModels of Models, which is constrained to Models<TModels>. It might be a bit confusing, but this was an unintended design on my part. Initially, I directly used a default parameter:
| |
But the above approach indeed uses any, and as someone who strictly disciplines themselves as a “TS Gymnast,” I of course want to reduce the occurrence of any. Therefore, I planned to change it to never or unknown. However, I quickly encountered a problem:
If the generic parameter TModels of Model uses Record<string, never>, then the type of effects, one of the properties of Model, would be ModelEffectsCreator<Record<string, never>>. Since ModelEffectsCreator is a function, its parameter would be inferred as RematchDispatch<Record<string, never>>.
As I mentioned in a previous article, function parameter compatibility is contravariant, so here we only need to determine whether RematchDispatch<Record<string, never>> is compatible with RematchDispatch<TModels> (i.e., whether the latter can be assigned to the former).
Let’s continue to analyze RematchDispatch, where we need to extract effects information from Model:
| |
But since Model at this time is never, then effects will also be never, so the index effectKey above will also be never. As we know, never cannot be compatible with any type other than itself, meaning any type other than itself cannot be assigned to it. Therefore, the above RematchDispatch<TModels> cannot be assigned to RematchDispatch<Record<string, never>>. So, in the end, it will report an error of compatibility with the index signature. Below is the error stack:
| |
Since never didn’t work, what about unknown?
| |
Clearly, using unknown here doesn’t even satisfy the constraint of extends Models, because unknown definitely cannot be assigned to Model.
Finally, because I always believed that if users are using Rematch, they would definitely define TModels, this generic parameter actually does not need a default value. So, I removed the default value, but without a default value, extends Models definitely wouldn’t work, so I happened to change it to extends Models<TModels>, and it eventually became the below somewhat strange circular reference:
| |
Although everything now works normally, I don’t actually understand this kind of writing. I only knew it could solve my current problem. To be honest, if it weren’t for writing this article, I might not have studied this place deeply, but writing forced me to understand this kind of writing. In the process of searching, I even found a good explanation.
Returning to my problem, why did I use this method? The primary reason is that I needed to create a constraint. As mentioned earlier, Model needs to get all the models’ information, and each individual Model acts as an attribute of Models. Therefore, to connect types, I needed to add the generic parameter TModels to Models, and TModels needs to meet the constraint, as it definitely is also a subset of Models, representing the user-defined global models type. So, the final constraint of TModels became extends Models<TModels>. It might still sound a bit convoluted, so let me give an actual example:
| |
If we substitute TModels with RootModel, it becomes much easier to understand. Here, I’m essentially trying to ensure that TModels is a subset of Models (i.e., the actual RootModel created by the user), not Models itself. If it’s still hard to understand, you can directly look at this answer, where the examples in the answer might be more appropriate.
The author also mentioned that TS supports polymorphic this, so my definition here could actually bypass the cumbersome and hard-to-understand “circular” and be changed to a more elegant way:
| |
This this can also represent the user-defined models. It’s worth noting that this is different from directly replacing it with Models. If we use Model<Models>, and at this time Models cannot represent the user-defined models, its keys are all string types, making it meaningless to pass to Model.
Of course, I haven’t practiced the above method yet. Later, I will find time to submit a PR to continue refactoring this part of the code.
4 Module Augmentation
TS has a feature called declaration merging, which has two merging modes for third-party modules and global environments: Module Augmentation and Global Augmentation, respectively. Augmentation means “enhancement, addition,” referring to expanding the functionality (at the type level) of a module or globally.
Global Augmentation is briefly mentioned here. If in a module (a file containing import, export keywords), additional declarations need to be placed under declare global {}:
| |
If it’s already a global script file, then there’s no need to add the declare global {} block.
Here, let’s mainly talk about Module Augmentation. Rematch uses this mode in 3 places to eliminate type incompatibility.
4.1 @rematch/core
First is declare module '@rematch/core' {}, used in both the select and typed-state plugins. Let’s first look at the select plugin:
| |
The select plugin adds a selectors attribute to Model and also exports a select function, mounted on RematchStore.
When explaining the createModel helper earlier, I mentioned why ModelCreator is defined as an interface. Since the select plugin supports defining selectors in the model, it can conveniently utilize Module Augmentation for function overloading.
The typed-state plugin is mainly for developers using Rematch purely in JS. Through the typings configuration, it helps to identify some erroneously defined types in the development environment. It also uses Module Augmentation:
| |
4.2 redux
@rematch/core also uses Module Augmentation for redux:
| |
When previously mentioning RematchDispatch, I talked about it being a composite type, combined with Rematch’s own dispatcher and ReduxDispatch (& ReduxDispatch). Below is ReduxDispatch:
| |
Adding the above definition and using any can eliminate many type errors in the source code. For developers using Rematch, models information is predefined, but in the source code, it can only be expressed using the generic TModels. There are several errors in this area, some of which I couldn’t find the reason for and just used any to avoid them. This part could be considered a lingering issue, and interested developers are welcome to check out the source code and submit PRs if they can resolve it.
You might wonder why we need to define a separate RematchDispatch instead of just using Module Augmentation. This is because TS requires declarations with the same name to use the same generic parameters, and RematchDispatch needs TModels information, which cannot be consistent with ReduxDispatch.
4.3 react-redux
Rematch also made compatibility adjustments for the connect method in react-redux. Since connect can only recognize ReduxDispatch, it needs to be overloaded to support RematchDispatch:
| |
By overloading, we introduce the generic RM, which is TModels, so that the dispatch parameter can be compatible, making connect compatible as well.
It’s worth mentioning that, according to Redux’s StyleGuide, it is recommended to use hooks, i.e., useSelector and useDispatch, instead of connect. This is also applicable to Rematch. The Redux team also believes that connect’s type definitions are overly complex and difficult to use, with too many function overloads, optional parameters, and the need to merge props, etc. Interested readers can check the link for more details.
While same-named declarations require the same generic parameters, if the declaration is used for function overloading, the overloaded functions’ generic parameters can be different.
5 Problem Summary
Finally, I picked a few typical issues, some of which have been resolved and some are still unresolved. I searched a lot of material for these issues and even found some design limitations of TS, all of which are quite interesting and worth discussing.
5.1 【Resolved】dispatcher inference
The original PR can be viewed here.
I previously mentioned RematchDispatch, which is actually a core part of Rematch and has complex type inference. For example, a reducer defined in a model has three parameters: modelState, payload, and meta, but when calling dispatch, only payload and meta need to be passed. In an effect, there are also three parameters: payload, rootState, and meta, with payload and meta being passed during the call.
Extracting parameters and generating new function definitions seems simple but has its challenges.
Let’s look at the extraction of reducers:
| |
Note that RematchDispatcher uses void as the default generic parameter. It was only while writing this article that I discovered a bug. Because void, apart from itself, can only be assigned to any and unknown, but the reverse behavior is quite strange:
| |
Due to the validity of case1 above, when the reducer’s second parameter payload is defined by the user as any, the generated dispatch function will have no parameters, which is clearly incorrect.
In fact, the compatibility of void is better than never, with never only being compatible with itself, but any behaves the same as void. However, using [] later changes this:
| |
I will use this point to submit a PR later to fix this issue.
Returning to the main topic, the inference logic is as follows:
- If the user hasn’t defined parameters, or only used state -> the dispatch parameter is empty.
- Otherwise, extract the first parameter
payloadand the second parametermeta:- If
metais not defined, i.e.,[TMeta] extends [void]:payloadis optional, i.e.,undefined extends TPayload-> dispatch with an optionalpayload- Otherwise -> dispatch with a mandatory
payload
- Otherwise, extract
TMetaand determine:- If both
metaandpayloadare optional, i.e.,[undefined, undefined] extends [TPayload, TMeta]-> dispatch with both parameters optional - Otherwise,
payloadis mandatory, and determineTMeta:TMetais optional, i.e.,undefined extends TMeta-> dispatch with mandatorypayloadand optionalmeta- Otherwise -> dispatch with both parameters mandatory
- If both
- If
It can be observed that we made an optimization above. For instance, even if the user defines (state, payload: number | undefined), the corresponding payload in the generated dispatch function will be optional, which is logically reasonable (this deserves more discussion, and some people believe that such parameters should be mandatory, even if passing an undefined). But the normal definition is still payload?: number.
Reducer inference is relatively simple because state is the first parameter, and the user-defined parameters are all behind it. But effect inference is more complex, mainly reflected in two aspects:
- An effect can be an object, that is, the
ModelEffectsmentioned above, or a functionModelEffectsCreator. - In the effect, the
rootStateparameter is second,payloadis first, andmetais last.
Regarding the second point, one might ask why not unify it. Because the original design considered practical usage. In reducers, the current modelState is generally needed, so it is placed as the first parameter. In effects, most of the time, only payload is needed, so rootState is moved to the middle, and the least used meta is placed last.
Let’s look at effect inference:
| |
First, we use extends (...args: any[]) to determine whether the effect is an object or a function. If it’s a function, we need to extract the return value; otherwise, use it directly. The key point to look at in the second step: here, I used a clever approach, which is to first judge the rootState parameter. If it is undefined, it means the user hasn’t defined this parameter, so we only need to consider TRest[0], which is payload. Next, check the type of rootState. Why use RematchRootState<TModels> extends TRest[1] instead of the reverse? Because rootState here is the second parameter, there is a situation: the user can define the first parameter payload as optional, and TS does not allow mandatory parameters to follow optional ones, so rootState also needs to be defined as optional. In this case, due to parameter contravariance, we must use the above order. More information can be found in this discussion. For why TS has such a restriction, there’s also a good answer.
If rootState is valid, then extract TRest[0] and TRest[2], along with the return information TReturn, and pass it to EffectRematchDispatcher. The subsequent steps are the same as for reducers, the only difference being the additional TReturn. Effects allow users to define their own return values, while reducers must return a ReduxAction.
Using
infer TRestto extract parameters has another advantage: if the parameter is not defined, when passed toEffectRematchDispatcher, if the generic uses a default value, the default value will be used. This is different from directly defining the parameter asundefined.
5.2 【Resolved】type guard
The original issue can be viewed here.
Let’s first look at a code snippet:
| |
The above error is indeed a problem with TS, and it still exists now. One solution is:
| |
But the example I simplified at the time was more complex, and using the above solution didn’t work. Although this issue was later fixed in TS v4.3.x, what did I do at the time?
Since I used NonNullable for the passed parameters, if the if statement couldn’t construct a NonNullable type, then I would construct it manually:
| |
I found that using both the Non-null assertion operator and the Nullish coalescing operator could achieve the desired effect. The former is TS syntax, seemingly constructing a NonNullable type for you, while the latter is JS syntax. After this operation, the value of hook becomes undefined | NonNullable<hook>, and then after passing through if, undefined is filtered out.
Especially the last method seemed quite magical, originally possibly being undefined (I refer to it as implicit), now this “implicit undefined” was converted into an explicit undefined, which could be filtered out. Of course, this is just my description; I didn’t fully understand the specific reasons, and since TS later fixed this issue, I didn’t delve further into it.
5.3 【Resolved】distributive conditional types
The original issue can be viewed here.
This issue is actually related to the dispatcher inference mentioned above, and it was probably raised before I rewrote this part. At that time, I did not know what a distributive conditional type was, and as mentioned before, the type inference of Dispatcher was overly complex (the front part was optimized by me later, the previous judgment was more complex). Looking at the large segments of conditional branches, I had no idea where the error originated from.
I remember using very naive methods at the time, manually breaking down the conditions and judging step by step, finally discovering the quirk. But since I didn’t know the concept of Distributive conditional types and searching was quite difficult, I eventually discovered this concept and suddenly everything became clear. Below is a simple example:
| |
The official explanation is as follows:
Conditional types in which the checked type is a naked type parameter are called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation. For example, an instantiation of T extends U ? X : Y with the type argument A | B | C for T is resolved as (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y).
I also inquired about why it was designed this way, and RyanCavanaugh’s answer is as follows:
Distributivity is usually what you want in those scenarios, so it’s the default when the checked type is a naked type parameter. This was in favor of making special syntax for distributivity/non-distributivity since, with this behavior, you just generally don’t have to think about it.
The general idea is that this is indeed a great design, and this design aligns with our regular thinking (which is why I didn’t notice anything unusual before), and this kind of distributive generics design can be used to develop many utility types:
| |
Well-known types such as Pick, Exclude, etc., are all based on this design.
Having discussed so many resolved issues, there are actually many unresolved ones, some limited by my current abilities, and some by TS limitations. I would be extremely grateful if everyone could help contribute to Rematch after reading this! Let’s take a look at those unresolved issues next.
5.4 【Unresolved】circularly reference
The original issue can be viewed here.
I previously mentioned Circularly Reference, mainly regarding the Models type. But there’s another kind of circular reference situation:
| |
As you can see, in the effectsB style, TS reports a circular reference error, but this is actually OK at the code level, because the writing of effectsB and effectsA is almost identical. The only difference is that effectsA always accesses foo through dispatch, while effectsB first destructures, which may cause foo in effectsB to always be undefined, as foo might be added to dispatch later. This problem can be avoided using the effectsA method. Rematch once had such an issue.
Although the above problem was resolved (the solution can be viewed in the issue link), TS still considers there to be a circular reference:
- The type of
Root['foo']istypeof foo- To know the type of
foo, the parameter type ofeffectsBis needed- The parameter type of
effectsBisExtract<Root>- The destructured
footype isExtract<Root>['foo'], i.e.,Root['foo']- Back to the first
- The destructured
- The parameter type of
- To know the type of
This problem has not been resolved, and there are several similar issues, some quite strange. Interested readers can refer to some of the following comments:
- https://github.com/microsoft/TypeScript/issues/35546#issuecomment-611485331
- https://github.com/microsoft/TypeScript/issues/35546#issuecomment-645036905
- https://github.com/microsoft/TypeScript/issues/35546#issuecomment-668287404
5.5 【Unresolved】partial arg inference
The original issue can be viewed here.
This issue actually involves two problems, one of which is the same as mentioned in the section on the createModel utility function, related to partial type parameter inference. The other problem was that I thought too simply, like in the following simplified example:
| |
In foo from the example above, I originally thought using the default parameter payload = 1 in setSomething could infer payload as number. I later realized that since we have type constraints on reducers in createModel, whether using default parameter assignment or explicitly declaring the parameter type, we need to ensure that this type is compatible with the constrained type. But default assignment does not change the inferred type.
Actually, in Rematch’s code, the type of Reducer is as follows:
| |
As mentioned earlier, since partial type inference is not possible (i.e., TState uses the user-defined generic, while payload is inferred), we defined payload as an optional any type. This way, since any type is compatible with any, users can narrow down its type when actually defining payload.
The optional and mandatory effects of
payloadabove are the same, becauseundefined | anyequalsany.
5.6 【Unresolved】Type Design for Reducer and Effect with the Same Name
As mentioned earlier, in Rematch, reducer can be named the same as effect. Moreover, when called, the reducer is executed first, followed by the effect. I’m not sure why it was designed this way initially, and this behavior is hidden from users.
In addition, this posed a significant challenge to type design, or even it was impossible to achieve. Below is a code snippet from the effect middleware:
| |
The above code first determines whether action.type exists in effects. If so, it calls the reducer first (next(action) represents calling the next middleware, with the reducer being executed last).
So, how should we consider this at the type level? Use a union to suggest two types? Or consider providing only the reducer type? I don’t think either solution is quite reasonable, although Sergio is currently using solution 2, which was mentioned earlier when discussing RematchDispatch. Let’s review it here:
| |
Due to the setting of isEffect: boolean, the same name reducer and effect lead to the appearance of the never type, making the function uncallable. So we switched to the MergeExclusive utility type for this task, which is relatively easy to understand. For example, for two types A and B, the end result is using union to replace intersection, but before the union, it does two things to A and B:
- For
A, set all attributes different fromBto?: never(since optional is equivalent toundefined, here it’s actuallyundefined | never, i.e.,undefined) - Then intersect with
B
Do the same for B and then unite them.
Returning to the front, what are the advantages and disadvantages of this method? The advantage is that when reducer and effect have the same name but completely different payloads, the use of a union type will cause payload to be never, thus uncallable, as seen in actionsAfter.decrement(1) above. Why is this an advantage? Because it perfectly matches Rematch’s design, as the action processed first by the reducer will continue to be passed to the effect. If the payload types of the two are completely different, it obviously could lead to errors.
What about the disadvantages? The disadvantage is also due to the union type. Look at actionsAfter.increment(1) above. The payload type of the reducer is a subset of the effect’s payload type, which is expected. For example, it will prompt payload: number, allowing the code to execute normally. But if reversed, TS can still ensure the successful execution of the code. However, since the reducer is executed first, the intention is to prompt the reducer type, but here it prompts the effect type, as seen in actionsAfter.incrementCopy('1') above.
Honestly, reducers and effects with the same name are indeed strange, and we should avoid this situation.
5.7 【Unresolved】this types
The original issue can be viewed here.
In the third article of this column, when discussing Rematch’s core plugins, I mentioned that the context this in the effect function is bound to dispatch[modelName]. This approach conveniently allows the use of this in the effect to dispatch all actions of the current model. However, this also poses challenges to type compatibility at the TypeScript (TS) level. The current definition of the effect type is as follows:
| |
If it were changed to:
| |
In that case, almost all types would need to add the TModel generic parameter, and it would also cause Model to become like this:
| |
This leads to Model also circularly referencing itself, similar to the Circularly Reference section mentioned earlier with Models. And as I mentioned, perhaps we can use this to represent the type itself in the type definition. If feasible, I think this approach can also be applied to Model. Currently, I do not have much time, but I will continue to pay attention to these two issues.
5.8 【Unresolved】select plugin types
The last unresolved issue is how to perfect the type definition for the select plugin. The code written by the original author of this plugin is quite complex, even more so than the reselect source code, which I find simpler. I cannot fully comprehend it, hence the type definition of this plugin is only slightly improved, and many parts are not thoroughly worked out. If anyone is interested, it would be great to delve into it and possibly fix it.
6 Summary
In fact, at the beginning of the reconstruction of the Rematch type system, my skill level was quite limited, so you will see that many of my designs were coincidental or accidental discoveries, just finding that they worked, which felt quite miraculous. But through writing this article, I traced back many of these “strange” designs and discovered more interesting aspects of TS. I hope everyone learns not only what things are but also understands why they are the way they are, and keeps the passion for learning!
This concludes this column. I hope that by reading all the articles, everyone can gain a deeper understanding of Rematch, thereby efficiently developing and mastering state management.




