Skip to content

Commit

Permalink
Add Variants
Browse files Browse the repository at this point in the history
  • Loading branch information
gvergnaud committed Jul 1, 2023
1 parent 5e69770 commit 7a404dc
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ import * as Pattern from './patterns';
export { match } from './match';
export { isMatching } from './is-matching';
export { Pattern, Pattern as P };
export { Variant, implementVariants } from './variants';
41 changes: 41 additions & 0 deletions src/variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Compute } from './types/helpers';
import { Pattern } from './types/Pattern';

export type Variant<k, d = never> = Compute<{ tag: k; value: d }>;

/**
* VariantPatterns can be used to match a Variant in a
* `match` expression.
*/
type VariantPattern<k, p> = { tag: k; value: p };

type AnyVariant = Variant<string, unknown>;

type Narrow<variant extends AnyVariant, k extends variant['tag']> = Extract<
variant,
Variant<k, unknown>
>;

type Constructor<k, v> = [v] extends [never]
? () => Variant<k>
: unknown extends v
? <t>(value: t) => Variant<k, t>
: {
(value: v): Variant<k, v>;
<p extends Pattern<v>>(pattern: p): VariantPattern<k, p>;
};

type Impl<variant extends AnyVariant> = {
[k in variant['tag']]: Constructor<k, Narrow<variant, k>['value']>;
};

export function implementVariants<variant extends AnyVariant>(): Impl<variant> {
return new Proxy({} as Impl<variant>, {
get: <k extends keyof Impl<variant>>(_: Impl<variant>, tag: k) => {
return (...args: [value?: Narrow<variant, k>['value']]) => ({
tag,
...(args.length === 0 ? {} : { value: args[0] }),
});
},
});
}
166 changes: 166 additions & 0 deletions tests/variants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { match, Variant, implementVariants, P } from '../src';

// APP code
type Shape =
| Variant<'Circle', { radius: number }>
| Variant<'Square', { sideLength: number }>
| Variant<'Rectangle', { x: number; y: number }>
| Variant<'Blob', number>;

type Maybe<T> = Variant<'Just', T> | Variant<'Nothing'>;

const { Just, Nothing } = implementVariants<Maybe<unknown>>();
const { Circle, Square, Rectangle, Blob } = implementVariants<Shape>();

describe('Variants', () => {
it('should work with exhaustive matching', () => {
const area = (x: Shape) =>
match(x)
.with(Circle({ radius: P.select() }), (radius) => Math.PI * radius ** 2)
.with(Square(P.select()), ({ sideLength }) => sideLength ** 2)
.with(Rectangle(P.select()), ({ x, y }) => x * y)
.with(Blob(P._), ({ value }) => value)
.exhaustive();

expect(area(Circle({ radius: 1 }))).toEqual(Math.PI);
expect(area(Square({ sideLength: 10 }))).toEqual(100);
expect(area(Blob(0))).toEqual(0);

// @ts-expect-error
expect(() => area({ tag: 'UUUPPs' })).toThrow();
});

it('should be possible to nest variants in data structures', () => {
const shapesAreEqual = (a: Shape, b: Shape) =>
match({ a, b })
.with(
{
a: Circle({ radius: P.select('a') }),
b: Circle({ radius: P.select('b') }),
},
({ a, b }) => a === b
)
.with(
{
a: Rectangle(P.select('a')),
b: Rectangle(P.select('b')),
},
({ a, b }) => a.x === b.x && a.y === b.y
)
.with(
{
a: Square({ sideLength: P.select('a') }),
b: Square({ sideLength: P.select('b') }),
},
({ a, b }) => a === b
)
.with(
{
a: Blob(P.select('a')),
b: Blob(P.select('b')),
},
({ a, b }) => a === b
)
.otherwise(() => false);

expect(
shapesAreEqual(Circle({ radius: 2 }), Circle({ radius: 2 }))
).toEqual(true);
expect(
shapesAreEqual(Circle({ radius: 2 }), Circle({ radius: 5 }))
).toEqual(false);
expect(
shapesAreEqual(Square({ sideLength: 2 }), Circle({ radius: 5 }))
).toEqual(false);
});

it('Variants with type parameters should work', () => {
const toString = (maybeShape: Maybe<Shape>) =>
match(maybeShape)
.with(Nothing(), () => 'Nothing')
.with(
Just(Circle({ radius: P.select() })),
(radius) => `Just Circle { radius: ${radius} }`
)
.with(
Just(Square(P.select())),
({ sideLength }) => `Just Square sideLength: ${sideLength}`
)
.with(
Just(Rectangle(P.select())),
({ x, y }) => `Just Rectangle { x: ${x}, y: ${y} }`
)
.with(Just(Blob(P.select())), (area) => `Just Blob { area: ${area} }`)
.exhaustive();

expect(toString(Just(Circle({ radius: 20 })))).toEqual(
`Just Circle { radius: 20 }`
);
expect(toString(Nothing())).toEqual(`Nothing`);
});

it('should be possible to put a union type in a variant', () => {
// with a normal union

const maybeAndUnion = (
x: Maybe<{ type: 't'; value: string } | { type: 'u'; value: number }>
) =>
match(x)
.with(Nothing(), () => 'Non')
.with(
Just({ type: 't' as const, value: P.select() }),
(x) => 'typeof x: string'
)
.with(
Just({ type: 'u' as const, value: P.select() }),
(x) => 'typeof x: number'
)
.exhaustive();

expect(maybeAndUnion(Nothing())).toEqual('Non');
expect(maybeAndUnion(Just({ type: 't', value: 'hello' }))).toEqual(
'typeof x: string'
);
expect(maybeAndUnion(Just({ type: 'u', value: 2 }))).toEqual(
'typeof x: number'
);
});

it('should be possible to create a variant with several type parameters', () => {
// Result
type Result<E, A> = Variant<'Success', A> | Variant<'Err', E>;

const { Success, Err } = implementVariants<Result<unknown, unknown>>();

type SomeRes = Result<string, { hello: string }>;

const x = true ? Success({ hello: 'coucou' }) : Err('lol');

const y: SomeRes = x;

const complexMatch = (x: Result<string, { shape: Shape }>) => {
return match(x)
.with(Err(P.select()), (msg) => `Error: ${msg}`)
.with(
Success({ shape: Circle(P.select()) }),
({ radius }) => `Circle ${radius}`
)
.with(
Success({ shape: Square(P.select()) }),
({ sideLength }) => `Square ${sideLength}`
)
.with(Success({ shape: Blob(P.select()) }), (area) => `Blob ${area}`)
.with(
Success({ shape: Rectangle(P.select()) }),
({ x, y }) => `Rectangle ${x + y}`
)
.exhaustive();
};

expect(complexMatch(Success({ shape: Circle({ radius: 20 }) }))).toEqual(
'Circle 20'
);
expect(complexMatch(Success({ shape: Blob(20) }))).toEqual('Blob 20');
expect(complexMatch(Err('Failed'))).toEqual('Error: Failed');
});
});

0 comments on commit 7a404dc

Please sign in to comment.