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 Annotation
s and every Annotation
belongs to exactly one Snippet
.
Our first stab might be something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// models/Snippet.ts import { types as t } from "mobx-state-tree"; import { v4 as uuid } from "uuid"; import { Annotation } from "./Annotation"; export const Snippet = t.model("Snippet", { id: t.optional(t.identifier, () => uuid()), annotations: t.array(t.late(() => Annotation)) }); // models/Annotation.ts import { types as t } from "mobx-state-tree"; import { v4 as uuid } from "uuid"; import { Snippet } from "./Snippet"; export const Annotation = t.model("Annotation", { id: t.optional(t.identifier, () => uuid()), snippet: t.reference(t.late(() => Snippet)) }); |
However, this will not work out well because of the aforementioned issue with circular dependency, and we will get following error:
1 2 3 |
'Snippet' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. 'Annotation' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. |
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:
1234567
const Snippet$1 = t.model("Snippet", { id: t.optional(t.identifier, () => uuid()),}); export const Snippet = Snippet$1.props({ annotations: t.array(t.late(() => Annotation))});
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:
- 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.
-
The type of an MST model is
IType<ISnapshotInType, ISnapshotOutType, IInstanceType>
where:ISnapshotInType
is what we can pass toModel.create
.ISnapshotOutType
is what we get back inonSnapshot
hook.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:
1234
interface ISnippet { id: string; annotations: Annotation[];}
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:
123456789101112131415161718192021
const Snippet$1 = t.model("Snippet", { id: t.optional(t.identifier, () => uuid()),}); export interface ISnippet extends Instance<typeof Snippet$1> { annotations: IAnnotation[];} export interface ISnippetSnapshotIn extends SnapshotIn<typeof Snippet$1> { annotations?: IAnnotationSnapshotIn[];} export interface ISnippetSnapshotOut extends SnapshotOut<typeof Snippet$1> { annotations: IAnnotationSnapshotOut[];} export type ISnippetRunType = IType<ISnippetSnapshotIn, ISnippetSnapshotOut, ISnippet>; export const Snippet: ISnippetRunType = Snippet$1.props({ annotations: t.array(t.late(() => Annotation))});
Similarly, for Annotation
:
123456789101112131415161718192021
const Annotation$1 = t.model("Annotation", { id: t.optional(t.identifier, () => uuid()),}); export interface IAnnotation extends Instance<typeof Annotation$1> { snippet: ISnippet}; export interface IAnnotationSnapshotIn extends SnapshotIn<typeof Annotation$1> { snippet: ReferenceIdentifier;} export interface IAnnotationSnapshotOut extends SnapshotOut<typeof Annotation$1> { snippet: ReferenceIdentifier;} export type IAnnotationRunType = IType<IAnnotationSnapshotIn, IAnnotationSnapshotOut, IAnnotation>; export const Annotation: IAnnotationRunType = Annotation$1.props({ snippet: t.reference(t.late(() => Snippet))});
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
:
1234
export const Snippet: ISnippetRunType = Snippet$1.props({ annotations: t.array(t.late(() => Annotation)), title: t.string});
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:
1
const snippet = Snippet.create({title: "foo"});
Above code fails with:
1 2 |
Argument of type '{ title: string; }' is not assignable to parameter of type 'ISnippetSnapshotIn'. Object literal may only specify known properties, and 'title' does not exist in type 'ISnippetSnapshotIn'. |
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:
1 2 3 |
type ExtendsWitness<U extends T, T> = U |
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
:
1 2 3 4 5 6 |
export const Snippet = Snippet$1.props({ annotations: t.array(t.late((): any => Annotation)), title: t.string }); |
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):
1 2 3 4 |
type _ISnippetSnapshotInWitness = ExtendsWitness<ISnippetSnapshotIn, SnapshotIn<typeof Snippet>>; type _ISnippetSnapshotOutWitness = ExtendsWitness<ISnippetSnapshotOut, SnapshotOut<typeof Snippet>>; |
Our compiler will now start complaining about that title:
1 2 3 |
Type 'ISnippetSnapshotOut' does not satisfy the constraint 'ModelSnapshotType<{ id: IOptionalIType<ISimpleType<string>, [undefined]>; } & { annotations: IArrayType<any>; title: ISimpleType<string>; }>'. Type 'ISnippetSnapshotOut' does not satisfy the constraint 'ModelSnapshotType<{ id: IOptionalIType<ISimpleType<string>, [undefined]>; } & { annotations: IArrayType<any>; title: ISimpleType<string>; }>'. |
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
:
12345678
const Snippet$1 = t.model("Snippet", { id: t.optional(t.identifier, () => uuid()), title: t.string}); export const Snippet = Snippet$1.props({ annotations: t.array(t.late((): any => Annotation))});
If we define a similar witness type for ISnippet
we will encounter another error:
type _ISnippetWitness = ExtendsWitness
12345
Type 'ISnippet' does not satisfy the constraint 'ModelInstanceTypeProps<{ id: IOptionalIType<ISimpleType<string>, [undefined]>; title: ISimpleType<string>; } & { annotations: IArrayType<any>; }> & IStateTreeNode<IModelType<{ id: IOptionalIType<ISimpleType<string>, [...]>; title: ISimpleType<...>; } & { ...; }, {}, _NotCustomized, _NotCustomized>>'. Type 'ISnippet' is not assignable to type 'ModelInstanceTypeProps<{ id: IOptionalIType<ISimpleType<string>, [undefined]>; title: ISimpleType<string>; } & { annotations: IArrayType<any>; }>'. Types of property 'annotations' are incompatible. Type 'IAnnotation[]' is not assignable to type 'IMSTArray<any> & IStateTreeNode<IArrayType<any>>'. Type 'IAnnotation[]' is missing the following properties from type 'IMSTArray<any>': spliceWithArray, observe, intercept, clear, and 4 more. [2344]
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
:
123
export interface ISnippet extends Instance<typeof Snippet$1> { annotations: IMSTArray<IAnnotationRunType>;}
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
:
1234567891011121314151617181920212223
export interface ISnippet extends Instance<typeof Snippet$1> { annotations: IMSTArray<IAnnotationRunType>;} export interface ISnippetSnapshotIn extends SnapshotIn<typeof Snippet$1> { annotations?: IAnnotationSnapshotIn[];} export interface ISnippetSnapshotOut extends SnapshotOut<typeof Snippet$1> { annotations: IAnnotationSnapshotOut[];} export interface ISnippetRunType extends IType<ISnippetSnapshotIn, ISnippetSnapshotOut, ISnippet> { } const Snippet$2 = Snippet$1.props({ annotations: t.array(t.late((): IAnnotationRunType => Annotation))}); type _ISnippetSnapshotInWitness = ExtendsWitness<ISnippetSnapshotIn, SnapshotIn<typeof Snippet>>;type _ISnippetSnapshotOutWitness = ExtendsWitness<ISnippetSnapshotOut, SnapshotOut<typeof Snippet>>;type _ISnippetWitness = ExtendsWitness<ISnippet, Instance<typeof Snippet>>; export const Snippet: ISnippetRunType = Snippet$2;
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
:
123456789101112
export const Annotation = t.model("Annotation", { id: t.optional(t.identifier, () => uuid()), snippet: t.reference(t.late((): ISnippetRunType => Snippet))}); export interface IAnnotation extends Instance<typeof Annotation> { }; export interface IAnnotationSnapshotIn extends SnapshotIn<typeof Annotation> { } export interface IAnnotationSnapshotOut extends SnapshotOut<typeof Annotation> { } export interface IAnnotationRunType extends IType<IAnnotationSnapshotIn, IAnnotationSnapshotOut, IAnnotation> { }
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.