Skip to content

Commit 8e6832a

Browse files
authored
Merge pull request #43 from gadget-inc/union-optimization
Allow union type instantiation to be faster by specifying a discriminator property
2 parents 6e31b89 + 07db9de commit 8e6832a

File tree

7 files changed

+296
-52
lines changed

7 files changed

+296
-52
lines changed

spec/simple.spec.ts

+1-40
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getSnapshot, types } from "../src";
1+
import { types } from "../src";
22

33
describe("boolean", () => {
44
test("can create a read-only instance", () => {
@@ -64,45 +64,6 @@ describe("literal", () => {
6464
});
6565
});
6666

67-
describe("union", () => {
68-
const unionType = types.union(types.literal("value 1"), types.literal("value 2"));
69-
70-
test("can create a read-only instance", () => {
71-
expect(unionType.createReadOnly("value 1")).toEqual("value 1");
72-
expect(unionType.createReadOnly("value 2")).toEqual("value 2");
73-
expect(() => unionType.createReadOnly("value 3" as any)).toThrow();
74-
});
75-
76-
test("can create an eager, read-only instance", () => {
77-
const unionType = types.lazyUnion(types.literal("value 1"), types.literal("value 2"));
78-
expect(unionType.createReadOnly("value 1")).toEqual("value 1");
79-
expect(unionType.createReadOnly("value 2")).toEqual("value 2");
80-
expect(() => unionType.createReadOnly("value 3" as any)).toThrow();
81-
});
82-
83-
test("can be verified with is", () => {
84-
expect(unionType.is("value 1")).toEqual(true);
85-
expect(unionType.is("value 2")).toEqual(true);
86-
expect(unionType.is("value 3")).toEqual(false);
87-
expect(unionType.is(null)).toEqual(false);
88-
expect(unionType.is(true)).toEqual(false);
89-
expect(unionType.is({})).toEqual(false);
90-
});
91-
92-
test("can create a union of model types", () => {
93-
const modelTypeA = types.model({ x: types.string });
94-
const modelTypeB = types.model({ y: types.number });
95-
const unionType = types.union(modelTypeA, modelTypeB);
96-
const unionInstance = unionType.createReadOnly({ x: "test" });
97-
98-
expect(getSnapshot(unionInstance)).toEqual(
99-
expect.objectContaining({
100-
x: "test",
101-
})
102-
);
103-
});
104-
});
105-
10667
describe("refinement", () => {
10768
const smallStringsType = types.refinement(types.string, (v: string) => v.length <= 5);
10869

spec/union.spec.ts

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { getSnapshot, types } from "../src";
2+
import { create } from "./helpers";
3+
4+
describe("union", () => {
5+
const unionType = types.union(types.literal("value 1"), types.literal("value 2"));
6+
7+
test("can be verified with is", () => {
8+
expect(unionType.is("value 1")).toEqual(true);
9+
expect(unionType.is("value 2")).toEqual(true);
10+
expect(unionType.is("value 3")).toEqual(false);
11+
expect(unionType.is(null)).toEqual(false);
12+
expect(unionType.is(true)).toEqual(false);
13+
expect(unionType.is({})).toEqual(false);
14+
});
15+
16+
describe.each([
17+
["readonly", true],
18+
["observable", false],
19+
])("%s nodes", (_, readonly) => {
20+
test("can create an instance", () => {
21+
expect(create(unionType, "value 1", readonly)).toEqual("value 1");
22+
expect(create(unionType, "value 2", readonly)).toEqual("value 2");
23+
expect(() => create(unionType, "value 3" as any, readonly)).toThrow();
24+
});
25+
26+
test("can create an eager, read-only instance", () => {
27+
const unionType = types.lazyUnion(types.literal("value 1"), types.literal("value 2"));
28+
expect(create(unionType, "value 1", readonly)).toEqual("value 1");
29+
expect(create(unionType, "value 2", readonly)).toEqual("value 2");
30+
expect(() => create(unionType, "value 3" as any, readonly)).toThrow();
31+
});
32+
33+
test("can create a union of model types", () => {
34+
const modelTypeA = types.model({ x: types.string });
35+
const modelTypeB = types.model({ y: types.number });
36+
const unionType = types.union(modelTypeA, modelTypeB);
37+
const unionInstance = create(unionType, { x: "test" }, readonly);
38+
39+
expect(getSnapshot(unionInstance)).toEqual(
40+
expect.objectContaining({
41+
x: "test",
42+
})
43+
);
44+
});
45+
46+
test("nested unions", () => {
47+
const one = types.union(types.literal("A"), types.literal("B"));
48+
const two = types.union(types.literal("C"), types.literal("D"));
49+
const Union = types.union(one, two);
50+
51+
const aInstance = create(Union, "A", readonly);
52+
expect(aInstance).toEqual("A");
53+
54+
const dInstance = create(Union, "D", readonly);
55+
expect(dInstance).toEqual("D");
56+
});
57+
58+
describe("with an explicit dispatcher option", () => {
59+
test("can use a dispatcher", () => {
60+
const Apple = types.model({ color: types.string });
61+
const Banana = types.model({ ripeness: types.number });
62+
const Union = types.union(
63+
{
64+
dispatcher: (snapshot: any) => {
65+
if (snapshot.color) {
66+
return Apple;
67+
} else {
68+
return Banana;
69+
}
70+
},
71+
},
72+
Apple,
73+
Banana
74+
);
75+
76+
const appleInstance = create(Union, { color: "red" }, readonly);
77+
expect(Apple.is(appleInstance)).toBeTruthy();
78+
expect(Banana.is(appleInstance)).toBeFalsy();
79+
80+
const bananaInstance = create(Union, { ripeness: 2 }, readonly);
81+
expect(Apple.is(bananaInstance)).toBeFalsy();
82+
expect(Banana.is(bananaInstance)).toBeTruthy();
83+
});
84+
});
85+
86+
describe("with an explicit discriminator property", () => {
87+
test("can use a literal discriminator", () => {
88+
const Apple = types.model({ type: types.literal("apple"), color: types.string });
89+
const Banana = types.model({ type: types.literal("banana"), ripeness: types.number });
90+
const Union = types.union({ discriminator: "type" }, Apple, Banana);
91+
92+
const appleInstance = create(Union, { type: "apple", color: "red" }, readonly);
93+
expect(Apple.is(appleInstance)).toBeTruthy();
94+
expect(Banana.is(appleInstance)).toBeFalsy();
95+
96+
const bananaInstance = create(Union, { type: "banana", ripeness: 2 }, readonly);
97+
expect(Apple.is(bananaInstance)).toBeFalsy();
98+
expect(Banana.is(bananaInstance)).toBeTruthy();
99+
});
100+
101+
test("can use an optional literal discriminator", () => {
102+
const Apple = types.model({ type: types.optional(types.literal("apple"), "apple"), color: types.string });
103+
const Banana = types.model({ type: types.optional(types.literal("banana"), "banana"), ripeness: types.number });
104+
const Union = types.union({ discriminator: "type" }, Apple, Banana);
105+
106+
const appleInstance = create(Union, { type: "apple", color: "red" }, readonly);
107+
expect(Apple.is(appleInstance)).toBeTruthy();
108+
expect(Banana.is(appleInstance)).toBeFalsy();
109+
110+
const bananaInstance = create(Union, { type: "banana", ripeness: 2 }, readonly);
111+
expect(Apple.is(bananaInstance)).toBeFalsy();
112+
expect(Banana.is(bananaInstance)).toBeTruthy();
113+
});
114+
115+
test("does not get an error when passing a snapshot that doesn't have a value for the discriminator property", () => {
116+
const Apple = types.model({ type: types.optional(types.literal("apple"), "apple"), color: types.string });
117+
const Banana = types.model({ type: types.optional(types.literal("banana"), "banana"), ripeness: types.number });
118+
const Union = types.union({ discriminator: "type" }, Apple, Banana);
119+
120+
const apple = create(Union, { color: "red" }, readonly);
121+
expect(Apple.is(apple)).toBeTruthy();
122+
});
123+
124+
test("can use an literal discriminators within nested unions", () => {
125+
const Apple = types.model({ type: types.optional(types.literal("apple"), "apple"), color: types.string });
126+
const Banana = types.model({ type: types.optional(types.literal("banana"), "banana"), ripeness: types.number });
127+
const InnerUnion = types.union({ discriminator: "type" }, Apple, Banana);
128+
const Pear = types.model({ type: types.optional(types.literal("pear"), "pear"), species: types.string });
129+
const Union = types.union({ discriminator: "type" }, InnerUnion, Pear);
130+
131+
const appleInstance = create(Union, { type: "apple", color: "red" }, readonly);
132+
expect(Apple.is(appleInstance)).toBeTruthy();
133+
expect(Banana.is(appleInstance)).toBeFalsy();
134+
expect(Pear.is(appleInstance)).toBeFalsy();
135+
136+
const bananaInstance = create(Union, { type: "banana", ripeness: 2 }, readonly);
137+
expect(Apple.is(bananaInstance)).toBeFalsy();
138+
expect(Banana.is(bananaInstance)).toBeTruthy();
139+
expect(Pear.is(bananaInstance)).toBeFalsy();
140+
141+
const pearInstance = create(Union, { type: "pear", species: "anjou" }, readonly);
142+
expect(Apple.is(pearInstance)).toBeFalsy();
143+
expect(Banana.is(pearInstance)).toBeFalsy();
144+
expect(Pear.is(pearInstance)).toBeTruthy();
145+
});
146+
});
147+
});
148+
149+
test("gets an error when passing a snapshot that has an unrecognized value for the discriminator property", () => {
150+
const Apple = types.model({ type: types.optional(types.literal("apple"), "apple"), color: types.string });
151+
const Banana = types.model({ type: types.optional(types.literal("banana"), "banana"), ripeness: types.number });
152+
const Union = types.union({ discriminator: "type" }, Apple, Banana);
153+
154+
expect(() => create(Union, { type: "pear" } as any, true)).toThrowErrorMatchingInlineSnapshot(
155+
`"Discriminator property value \`pear\` for property \`type\` on incoming snapshot didn't correspond to a type. Options: apple, banana. Snapshot was \`{"type":"pear"}\`"`
156+
);
157+
});
158+
});

src/class-model.ts

+4
Original file line numberDiff line numberDiff line change
@@ -471,3 +471,7 @@ function getPropertyDescriptor(obj: any, property: string) {
471471
}
472472
return null;
473473
}
474+
475+
export const isClassModel = (type: IAnyType): type is IClassModelType<any, any, any> => {
476+
return (type as any).isMQTClassModel;
477+
};

src/errors.ts

+3
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ export class CantRunActionError extends Error {}
33

44
/** Thrown when an invalid registration is passed to the class model register function */
55
export class RegistrationError extends Error {}
6+
7+
/** Thrown when a type in a union can't be used for discrimination because the value of the descriminator property can't be determined at runtime */
8+
export class InvalidDiscriminatorError extends Error {}

src/optional.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313

1414
export type DefaultFuncOrValue<T extends IAnyType> = T["InputType"] | T["OutputType"] | (() => CreateTypes<T>);
1515

16-
class OptionalType<T extends IAnyType, OptionalValues extends [ValidOptionalValue, ...ValidOptionalValue[]]> extends BaseType<
16+
export class OptionalType<T extends IAnyType, OptionalValues extends [ValidOptionalValue, ...ValidOptionalValue[]]> extends BaseType<
1717
T["InputType"] | OptionalValues[number],
1818
T["OutputType"],
1919
InstanceWithoutSTNTypeForType<T>

src/simple.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export class NullType extends BaseType<null, null, null> {
8080
}
8181
}
8282

83-
class LiteralType<T extends Primitives> extends SimpleType<T> {
83+
export class LiteralType<T extends Primitives> extends SimpleType<T> {
8484
constructor(readonly value: T) {
8585
super(typeof value, types.literal<T>(value));
8686
}

0 commit comments

Comments
 (0)