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
)
)
}
)
;
'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:
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 to
Model.create
.
ISnapshotOutType
is what we get back in
onSnapshot
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:
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
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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
)
)
}
)
;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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
:
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:
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):
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
:
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
:
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
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
:
1
2
3
4
5
6
7
8
9
10
11
12
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 🖖.