Skip to content

Commit 7a404dc

Browse files
committed
Add Variants
1 parent 5e69770 commit 7a404dc

File tree

3 files changed

+208
-0
lines changed

3 files changed

+208
-0
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ import * as Pattern from './patterns';
33
export { match } from './match';
44
export { isMatching } from './is-matching';
55
export { Pattern, Pattern as P };
6+
export { Variant, implementVariants } from './variants';

src/variants.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Compute } from './types/helpers';
2+
import { Pattern } from './types/Pattern';
3+
4+
export type Variant<k, d = never> = Compute<{ tag: k; value: d }>;
5+
6+
/**
7+
* VariantPatterns can be used to match a Variant in a
8+
* `match` expression.
9+
*/
10+
type VariantPattern<k, p> = { tag: k; value: p };
11+
12+
type AnyVariant = Variant<string, unknown>;
13+
14+
type Narrow<variant extends AnyVariant, k extends variant['tag']> = Extract<
15+
variant,
16+
Variant<k, unknown>
17+
>;
18+
19+
type Constructor<k, v> = [v] extends [never]
20+
? () => Variant<k>
21+
: unknown extends v
22+
? <t>(value: t) => Variant<k, t>
23+
: {
24+
(value: v): Variant<k, v>;
25+
<p extends Pattern<v>>(pattern: p): VariantPattern<k, p>;
26+
};
27+
28+
type Impl<variant extends AnyVariant> = {
29+
[k in variant['tag']]: Constructor<k, Narrow<variant, k>['value']>;
30+
};
31+
32+
export function implementVariants<variant extends AnyVariant>(): Impl<variant> {
33+
return new Proxy({} as Impl<variant>, {
34+
get: <k extends keyof Impl<variant>>(_: Impl<variant>, tag: k) => {
35+
return (...args: [value?: Narrow<variant, k>['value']]) => ({
36+
tag,
37+
...(args.length === 0 ? {} : { value: args[0] }),
38+
});
39+
},
40+
});
41+
}

tests/variants.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { match, Variant, implementVariants, P } from '../src';
2+
3+
// APP code
4+
type Shape =
5+
| Variant<'Circle', { radius: number }>
6+
| Variant<'Square', { sideLength: number }>
7+
| Variant<'Rectangle', { x: number; y: number }>
8+
| Variant<'Blob', number>;
9+
10+
type Maybe<T> = Variant<'Just', T> | Variant<'Nothing'>;
11+
12+
const { Just, Nothing } = implementVariants<Maybe<unknown>>();
13+
const { Circle, Square, Rectangle, Blob } = implementVariants<Shape>();
14+
15+
describe('Variants', () => {
16+
it('should work with exhaustive matching', () => {
17+
const area = (x: Shape) =>
18+
match(x)
19+
.with(Circle({ radius: P.select() }), (radius) => Math.PI * radius ** 2)
20+
.with(Square(P.select()), ({ sideLength }) => sideLength ** 2)
21+
.with(Rectangle(P.select()), ({ x, y }) => x * y)
22+
.with(Blob(P._), ({ value }) => value)
23+
.exhaustive();
24+
25+
expect(area(Circle({ radius: 1 }))).toEqual(Math.PI);
26+
expect(area(Square({ sideLength: 10 }))).toEqual(100);
27+
expect(area(Blob(0))).toEqual(0);
28+
29+
// @ts-expect-error
30+
expect(() => area({ tag: 'UUUPPs' })).toThrow();
31+
});
32+
33+
it('should be possible to nest variants in data structures', () => {
34+
const shapesAreEqual = (a: Shape, b: Shape) =>
35+
match({ a, b })
36+
.with(
37+
{
38+
a: Circle({ radius: P.select('a') }),
39+
b: Circle({ radius: P.select('b') }),
40+
},
41+
({ a, b }) => a === b
42+
)
43+
.with(
44+
{
45+
a: Rectangle(P.select('a')),
46+
b: Rectangle(P.select('b')),
47+
},
48+
({ a, b }) => a.x === b.x && a.y === b.y
49+
)
50+
.with(
51+
{
52+
a: Square({ sideLength: P.select('a') }),
53+
b: Square({ sideLength: P.select('b') }),
54+
},
55+
({ a, b }) => a === b
56+
)
57+
.with(
58+
{
59+
a: Blob(P.select('a')),
60+
b: Blob(P.select('b')),
61+
},
62+
({ a, b }) => a === b
63+
)
64+
.otherwise(() => false);
65+
66+
expect(
67+
shapesAreEqual(Circle({ radius: 2 }), Circle({ radius: 2 }))
68+
).toEqual(true);
69+
expect(
70+
shapesAreEqual(Circle({ radius: 2 }), Circle({ radius: 5 }))
71+
).toEqual(false);
72+
expect(
73+
shapesAreEqual(Square({ sideLength: 2 }), Circle({ radius: 5 }))
74+
).toEqual(false);
75+
});
76+
77+
it('Variants with type parameters should work', () => {
78+
const toString = (maybeShape: Maybe<Shape>) =>
79+
match(maybeShape)
80+
.with(Nothing(), () => 'Nothing')
81+
.with(
82+
Just(Circle({ radius: P.select() })),
83+
(radius) => `Just Circle { radius: ${radius} }`
84+
)
85+
.with(
86+
Just(Square(P.select())),
87+
({ sideLength }) => `Just Square sideLength: ${sideLength}`
88+
)
89+
.with(
90+
Just(Rectangle(P.select())),
91+
({ x, y }) => `Just Rectangle { x: ${x}, y: ${y} }`
92+
)
93+
.with(Just(Blob(P.select())), (area) => `Just Blob { area: ${area} }`)
94+
.exhaustive();
95+
96+
expect(toString(Just(Circle({ radius: 20 })))).toEqual(
97+
`Just Circle { radius: 20 }`
98+
);
99+
expect(toString(Nothing())).toEqual(`Nothing`);
100+
});
101+
102+
it('should be possible to put a union type in a variant', () => {
103+
// with a normal union
104+
105+
const maybeAndUnion = (
106+
x: Maybe<{ type: 't'; value: string } | { type: 'u'; value: number }>
107+
) =>
108+
match(x)
109+
.with(Nothing(), () => 'Non')
110+
.with(
111+
Just({ type: 't' as const, value: P.select() }),
112+
(x) => 'typeof x: string'
113+
)
114+
.with(
115+
Just({ type: 'u' as const, value: P.select() }),
116+
(x) => 'typeof x: number'
117+
)
118+
.exhaustive();
119+
120+
expect(maybeAndUnion(Nothing())).toEqual('Non');
121+
expect(maybeAndUnion(Just({ type: 't', value: 'hello' }))).toEqual(
122+
'typeof x: string'
123+
);
124+
expect(maybeAndUnion(Just({ type: 'u', value: 2 }))).toEqual(
125+
'typeof x: number'
126+
);
127+
});
128+
129+
it('should be possible to create a variant with several type parameters', () => {
130+
// Result
131+
type Result<E, A> = Variant<'Success', A> | Variant<'Err', E>;
132+
133+
const { Success, Err } = implementVariants<Result<unknown, unknown>>();
134+
135+
type SomeRes = Result<string, { hello: string }>;
136+
137+
const x = true ? Success({ hello: 'coucou' }) : Err('lol');
138+
139+
const y: SomeRes = x;
140+
141+
const complexMatch = (x: Result<string, { shape: Shape }>) => {
142+
return match(x)
143+
.with(Err(P.select()), (msg) => `Error: ${msg}`)
144+
.with(
145+
Success({ shape: Circle(P.select()) }),
146+
({ radius }) => `Circle ${radius}`
147+
)
148+
.with(
149+
Success({ shape: Square(P.select()) }),
150+
({ sideLength }) => `Square ${sideLength}`
151+
)
152+
.with(Success({ shape: Blob(P.select()) }), (area) => `Blob ${area}`)
153+
.with(
154+
Success({ shape: Rectangle(P.select()) }),
155+
({ x, y }) => `Rectangle ${x + y}`
156+
)
157+
.exhaustive();
158+
};
159+
160+
expect(complexMatch(Success({ shape: Circle({ radius: 20 }) }))).toEqual(
161+
'Circle 20'
162+
);
163+
expect(complexMatch(Success({ shape: Blob(20) }))).toEqual('Blob 20');
164+
expect(complexMatch(Err('Failed'))).toEqual('Error: Failed');
165+
});
166+
});

0 commit comments

Comments
 (0)