添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

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 ) )
} ) ;
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:

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 [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:

    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):

    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 :

    1
    2
    3
    4
    5
    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 🖖.