Ramblings on Web Development and Software Architecture

Posted  2 years ago


Dealing with circular type references in Mobx-state-tree

A frequently occuring issue when creating interrelated MST models, is that of circular type references.

Usually we don’t don’t have to explicitly define interfaces for our models, because they can be inferred for us through the APIs exposed by MST. However, when defining models that depend on each other, this falls short because TypeScript’s type-inference is not good enough to circular dependencies.

For example, lets say we have a note taking application with Snippet and Annotation models. A Snippet can have many Annotations and every Annotation belongs to exactly one Snippet.

Our first stab might be something like this:

However, this will not work out well because of the aforementioned issue with circular dependency, and we will get following error:

We would want to resolve this, but at the same time, use the automatic inference as much as possible so we don’t have to define the entire model type ourselves.

MST allows us to define our models in multiple stages:

This split is not arbitrary. While we haven’t quite solved the problem yet, but we note that for Snippet$1 our model types can be inferred as there are no circular references there.

The idea is to augment the inferred type of Snippet$1 model with a manual specification of types of attributes which cause circular reference.

Before we start on that, lets take a step back and reflect on following two facts we can leverage:

  1. TypeScript interfaces can have circular references. While inferred types and type aliases are eager resolved (atleast as of this writing), interfaces can have mutual dependencies.

  2. The type of an MST model is IType<ISnapshotInType, ISnapshotOutType, IInstanceType> where:

    1. ISnapshotInType is what we can pass to Model.create.
    2. ISnapshotOutType is what we get back in onSnapshot hook.
    3. IInstanceType is the type of the model instances.

So for our case, if we were not using MST, we would have defined an ISnippet interface something like:

We can still do that, but the idea of this post is to avoid duplication of type definitions as much as possible because in real applications we would have many more attributes, and we wouldn’t want to keep them in sync across MST models and manually defined instance types.

If you are wondering why ISnapshotInType and ISnapshotOutType can be different, the answer is right there above. Our model has id as an optional attribute with a factory function for supplying default values. So in our ISnapshotInType for Snippet (lets call it ISnippetSnapshotIn), id will be optional, but in the outgoing snapshot type it will always be present.

MST also supports pre-process and post-process hooks and when using them our incoming and outgoing snapshot types will often diverge.

MST also allows us to extract[1] out the Snapshot types and Instance types for cases where inference is possible.

So Instance<typeof Snippet$1> gives us the Instance type of Snippet$1 model which is basically { id: string }.
Similarly we can extract out SnapshotIn<typeof Snippet$1> and SnapshotOut<typeof Snippet$1> which are the incoming and outgoing snapshot types respectively.

So, armed with above insights, lets us augment the extracted types from Snippet$1 with the additional attributes we need for our Snippet model:

Similarly, for Annotation:

This solves our problem and we can conclude here, but I wanted to take this opportunity to highlight a potential caveat with the above implementation.

Let’s say we decide to add a title field to our Snippet model, and we accidentally add it to Snippet:

Because we aren’t using the inferred type from Snippet and we haven’t manually updated the types of ISnippet, ISnippetSnapshotIn and ISnippetSnapshotOut, we will run into an error when we try to create a snippet with a title:

Above code fails with:

So, yeah we have type-safety and the type-error points us to the correct direction but we got that error only after we tried to instantiate Snippet with a title and nothing before then.

One might wonder what if we could make such a mistake impossible to make in the first place.

The solution, as suggested by SO user jcalz is through witness types which exist solely to check compatibility of types:

Now to take advantage of the ExtendsWitness we can update our Snippet model such that instead of specifying the type of Snippet we just explicitly substitute the parts causing circular dependency with any:

We don’t have a type error because the function passed to t.late explicitly returns any.

Now, lets define witness types for the types extracted from Snippet (which is possible because our use of any has eliminated the circular dependency issue):

Our compiler will now start complaining about that title:

Note that the order of types here is important, because ExtendsWitness<SnapshotIn<typeof Snippet>, ISnippetSnapshotIn> will happily pass. We need to ensure that what we are extracting after any-substitution remains a subtype of what we are declaring as our final type.

The reported errors will go away when we move our title from Snippet to Snippet$1:

If we define a similar witness type for ISnippet we will encounter another error:

type _ISnippetWitness = ExtendsWitness>;

This happens because our manually defined instance type ISnippet uses a plain array where in the instance we would actually have an IMSTArray which quacks like an array, but is also MST aware (handles references, snapshot types etc.) and obsevervable.

So we can update our ISnippet implementation to use an IMSTArray:

So the witnesses potentially safeguards against hard(-er) to debug errors at invocation sites by identifying them close to the definition site itself.

However, when we added witness types we removed our augmented type annotation from Snippet (export const Snippet: ISnippetRunType = ... to export const Snippet = ...). While this enabled us to add witnesses for the types derived from Snippet, these derived types have strictly less information than ISnippetRunType and so when exporting we would want to export a model of type ISnippetRunType:

Note that we have also replaced the previous type alias (ISnippetRunType) with an interface which we can use as the return types of t.late (because interfaces can have cyclic dependencies).

We could do exactly the same thing for Annotation.ts, but we can do better.

We can further take advantage of the fact that interfaces can have cyclic-dependencies to reduce the boilerplate in Annotation.ts to the extent that we won’t even need the intermediate type Annotation$1:

Obviously we can’t do this for both Snippet and Annotation because TypeScript will not allow us to define a type such that its definition will use itself.

So with this, we are finally done 🖖.

[1] My post on unwrapping composite types goes into more detail around TypeScript features that enable us to extract out types of members of a composite type.

PS: You’d note that we had to write quite a bit of boilerplate to ensure type-safety. I am writing an inline code-generator called InGenR that helps with automating this kind of thing using code-generation. If this interests you I’d be more than happy to receive feedback and contributions.