Skip to content

Commit 96f2e46

Browse files
authored
Fix model types when extending in any way. (#2218)
Previously, we used to have TS resolve the extensions by just doing something like `A & B`. The problem is that in TypeScript that means "it's both A and B", but what happens if you have a required prop in one and an optional prop in the other? Well, the least common denominator wins, which is requiredness, because it satisfies both the required and optional properties. MST doesn't work that way though. Whenever we extend a model we "clobber" the old property of the same name. To model this in TS we need to omit properties from the previous version of the model and replace them with the newer ones.
1 parent aeb4e8a commit 96f2e46

File tree

2 files changed

+157
-67
lines changed

2 files changed

+157
-67
lines changed

__tests__/core/type-system.test.ts

+104-24
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,37 @@
1+
import { describe, expect, test } from "bun:test"
12
import {
2-
types,
3-
getSnapshot,
4-
unprotect,
5-
getRoot,
6-
getParent,
7-
SnapshotOrInstance,
8-
cast,
9-
SnapshotIn,
10-
Instance,
11-
castToSnapshot,
12-
IType,
13-
isStateTreeNode,
14-
isFrozenType,
15-
TypeOfValue,
163
IAnyType,
4+
IType,
5+
Instance,
176
ModelPrimitive,
187
ModelPropertiesDeclaration,
8+
SnapshotIn,
9+
SnapshotOrInstance,
1910
SnapshotOut,
20-
type ISimpleType,
21-
isOptionalType,
22-
isUnionType,
23-
type IOptionalIType,
24-
type ITypeUnion,
25-
isMapType,
11+
TypeOfValue,
12+
cast,
13+
castToSnapshot,
14+
getParent,
15+
getRoot,
16+
getSnapshot,
2617
isArrayType,
27-
isModelType,
18+
isFrozenType,
19+
isIdentifierType,
20+
isLateType,
2821
isLiteralType,
22+
isMapType,
23+
isModelType,
24+
isOptionalType,
2925
isPrimitiveType,
3026
isReferenceType,
31-
isIdentifierType,
3227
isRefinementType,
33-
isLateType
28+
isStateTreeNode,
29+
isUnionType,
30+
types,
31+
unprotect,
32+
type IOptionalIType,
33+
type ISimpleType
3434
} from "../../src"
35-
import { describe, expect, test } from "bun:test"
3635
import type {
3736
DatePrimitive,
3837
IAnyComplexType,
@@ -1403,3 +1402,84 @@ test("#1627 - union dispatch function is typed", () => {
14031402
types.null
14041403
)
14051404
})
1405+
1406+
test("#2216 - should respect optionality when extending another type", () => {
1407+
const Base = types.model("ErrorStore", { value: types.string }).extend((self) => ({
1408+
actions: {
1409+
setValue(value?: string): boolean {
1410+
self.value = value || "test"
1411+
return true
1412+
},
1413+
1414+
setAnotherValue(value?: string): boolean {
1415+
self.value = value || "test"
1416+
return true
1417+
}
1418+
},
1419+
views: {
1420+
get spam(): string {
1421+
return self.value
1422+
},
1423+
1424+
get eggs(): string {
1425+
return self.value
1426+
}
1427+
},
1428+
state: {
1429+
anotherValue: "test" as string,
1430+
soManyValues: "test" as string
1431+
}
1432+
}))
1433+
1434+
const Extended = Base.named("Extended")
1435+
.props({
1436+
value: "test"
1437+
})
1438+
.extend((self) => ({
1439+
actions: {
1440+
setValue(value: string): number {
1441+
self.value = value
1442+
return value.length
1443+
}
1444+
},
1445+
views: {
1446+
get spam(): boolean {
1447+
return !!self.value
1448+
}
1449+
},
1450+
state: {
1451+
anotherValue: "test" as string | undefined
1452+
}
1453+
}))
1454+
.actions((self) => ({
1455+
setAnotherValue(value: string): number {
1456+
self.value = value
1457+
return value.length
1458+
}
1459+
}))
1460+
.views((self) => ({
1461+
get eggs(): boolean {
1462+
return !!self.value
1463+
}
1464+
}))
1465+
.volatile((self) => ({
1466+
soManyValues: "test" as string | undefined
1467+
}))
1468+
1469+
type InputSnapshot = SnapshotIn<typeof Extended>
1470+
type InstanceType = Instance<typeof Extended>
1471+
1472+
assertTypesEqual(_ as InputSnapshot, _ as { value?: string })
1473+
assertTypesEqual(
1474+
_ as InstanceType,
1475+
_ as {
1476+
value: string
1477+
setValue(value: string): number
1478+
setAnotherValue(value: string): number
1479+
spam: boolean
1480+
eggs: boolean
1481+
anotherValue: string | undefined
1482+
soManyValues: string | undefined
1483+
}
1484+
)
1485+
})

src/types/complex-types/model.ts

+53-43
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,60 @@
11
import {
2+
IObjectDidChange,
3+
IObjectWillChange,
24
_getAdministration,
35
_interceptReads,
46
action,
57
computed,
68
defineProperty,
7-
intercept,
89
getAtom,
9-
IObjectWillChange,
10+
intercept,
11+
makeObservable,
1012
observable,
1113
observe,
12-
set,
13-
IObjectDidChange,
14-
makeObservable
14+
set
1515
} from "mobx"
1616
import {
17-
addHiddenFinalProp,
18-
addHiddenWritableProp,
17+
AnyNode,
18+
AnyObjectNode,
1919
ArrayType,
2020
ComplexType,
21-
createActionInvoker,
22-
createObjectNode,
2321
EMPTY_ARRAY,
2422
EMPTY_OBJECT,
25-
escapeJsonPath,
23+
FunctionWithFlag,
24+
Hook,
25+
IAnyType,
26+
IChildNodesMap,
27+
IJsonPatch,
28+
IType,
29+
IValidationContext,
30+
IValidationResult,
31+
Instance,
32+
MapType,
2633
MstError,
34+
TypeFlags,
35+
_CustomOrOther,
36+
_NotCustomized,
37+
addHiddenFinalProp,
38+
addHiddenWritableProp,
39+
assertArg,
40+
assertIsString,
41+
createActionInvoker,
42+
createObjectNode,
43+
devMode,
44+
escapeJsonPath,
2745
flattenTypeErrors,
2846
freeze,
2947
getContextForPath,
3048
getPrimitiveFactoryFromValue,
3149
getStateTreeNode,
32-
IAnyType,
33-
IChildNodesMap,
34-
IValidationContext,
35-
IJsonPatch,
3650
isPlainObject,
3751
isPrimitive,
3852
isStateTreeNode,
3953
isType,
40-
IType,
41-
IValidationResult,
4254
mobxShallow,
4355
optional,
44-
MapType,
45-
typecheckInternal,
4656
typeCheckFailure,
47-
TypeFlags,
48-
Hook,
49-
AnyObjectNode,
50-
AnyNode,
51-
_CustomOrOther,
52-
_NotCustomized,
53-
Instance,
54-
devMode,
55-
assertIsString,
56-
assertArg,
57-
FunctionWithFlag,
58-
type IStateTreeNode
57+
typecheckInternal
5958
} from "../../internal"
6059

6160
const PRE_PROCESS_SNAPSHOT = "preProcessSnapshot"
@@ -74,6 +73,9 @@ export interface ModelPropertiesDeclaration {
7473
[key: string]: ModelPrimitive | IAnyType
7574
}
7675

76+
/** intersect two object types, but omit keys of B from A before doing so */
77+
type OmitMerge<A, B> = Omit<A, keyof B> & B
78+
7779
/**
7880
* Unmaps syntax property declarations to a map of { propName: IType }
7981
*
@@ -117,6 +119,8 @@ type IsOptionalValue<C, TV, FV> = undefined extends C ? TV : FV
117119
// type _E = IsOptionalValue<any, true, false> // true
118120
// type _F = IsOptionalValue<unknown, true, false> // true
119121

122+
type AnyObject = Record<string, any>
123+
120124
/**
121125
* Name of the properties of an object that can't be set to undefined, any or unknown
122126
* @hidden
@@ -199,23 +203,28 @@ export interface IModelType<
199203
// so it is recommended to use pre/post process snapshot after all props have been defined
200204
props<PROPS2 extends ModelPropertiesDeclaration>(
201205
props: PROPS2
202-
): IModelType<PROPS & ModelPropertiesDeclarationToProperties<PROPS2>, OTHERS, CustomC, CustomS>
206+
): IModelType<
207+
OmitMerge<PROPS, ModelPropertiesDeclarationToProperties<PROPS2>>,
208+
OTHERS,
209+
CustomC,
210+
CustomS
211+
>
203212

204-
views<V extends Object>(
213+
views<V extends AnyObject>(
205214
fn: (self: Instance<this>) => V
206-
): IModelType<PROPS, OTHERS & V, CustomC, CustomS>
215+
): IModelType<PROPS, OmitMerge<OTHERS, V>, CustomC, CustomS>
207216

208217
actions<A extends ModelActions>(
209218
fn: (self: Instance<this>) => A
210-
): IModelType<PROPS, OTHERS & A, CustomC, CustomS>
219+
): IModelType<PROPS, OmitMerge<OTHERS, A>, CustomC, CustomS>
211220

212-
volatile<TP extends object>(
213-
fn: (self: Instance<this>) => TP
214-
): IModelType<PROPS, OTHERS & TP, CustomC, CustomS>
221+
volatile<VS extends AnyObject>(
222+
fn: (self: Instance<this>) => VS
223+
): IModelType<PROPS, OmitMerge<OTHERS, VS>, CustomC, CustomS>
215224

216-
extend<A extends ModelActions = {}, V extends Object = {}, VS extends Object = {}>(
225+
extend<A extends ModelActions = {}, V extends AnyObject = {}, VS extends AnyObject = {}>(
217226
fn: (self: Instance<this>) => { actions?: A; views?: V; state?: VS }
218-
): IModelType<PROPS, OTHERS & A & V & VS, CustomC, CustomS>
227+
): IModelType<PROPS, OmitMerge<OTHERS, A & V & VS>, CustomC, CustomS>
219228

220229
preProcessSnapshot<NewC = ModelCreationType2<PROPS, CustomC>>(
221230
fn: (snapshot: NewC) => WithAdditionalProperties<ModelCreationType2<PROPS, CustomC>>
@@ -235,6 +244,7 @@ export interface IAnyModelType extends IModelType<any, any, any, any> {}
235244
export type ExtractProps<T extends IAnyModelType> = T extends IModelType<infer P, any, any, any>
236245
? P
237246
: never
247+
238248
/** @hidden */
239249
export type ExtractOthers<T extends IAnyModelType> = T extends IModelType<any, infer O, any, any>
240250
? O
@@ -463,7 +473,7 @@ export class ModelType<
463473
return this.cloneAndEnhance({ properties })
464474
}
465475

466-
volatile<TP extends object>(fn: (self: Instance<this>) => TP) {
476+
volatile<TP extends AnyObject>(fn: (self: Instance<this>) => TP) {
467477
if (typeof fn !== "function") {
468478
throw new MstError(
469479
`You passed an ${typeof fn} to volatile state as an argument, when function is expected`
@@ -496,7 +506,7 @@ export class ModelType<
496506
})
497507
}
498508

499-
extend<A extends ModelActions = {}, V extends Object = {}, VS extends Object = {}>(
509+
extend<A extends ModelActions = {}, V extends AnyObject = {}, VS extends AnyObject = {}>(
500510
fn: (self: Instance<this>) => { actions?: A; views?: V; state?: VS }
501511
) {
502512
const initializer = (self: Instance<this>) => {
@@ -513,15 +523,15 @@ export class ModelType<
513523
return this.cloneAndEnhance({ initializers: [initializer] })
514524
}
515525

516-
views<V extends Object>(fn: (self: Instance<this>) => V) {
526+
views<V extends AnyObject>(fn: (self: Instance<this>) => V) {
517527
const viewInitializer = (self: Instance<this>) => {
518528
this.instantiateViews(self, fn(self))
519529
return self
520530
}
521531
return this.cloneAndEnhance({ initializers: [viewInitializer] })
522532
}
523533

524-
private instantiateViews(self: this["T"], views: Object): void {
534+
private instantiateViews(self: this["T"], views: AnyObject): void {
525535
// check views return
526536
if (!isPlainObject(views)) {
527537
throw new MstError(`views initializer should return a plain object containing views`)

0 commit comments

Comments
 (0)