Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Variant types #45

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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';
11 changes: 7 additions & 4 deletions src/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,14 @@ export type unstable_Matcher<
* const userPattern = { name: P.string }
* type User = P.infer<typeof userPattern>
*/
export type infer<p extends Pattern<any>> = InvertPattern<p, unknown>;
export type infer<pattern extends Pattern<any>> = InvertPattern<
pattern,
unknown
>;

export type narrow<i, p extends Pattern<any>> = ExtractPreciseValue<
i,
InvertPattern<p, i>
export type narrow<input, pattern extends Pattern<any>> = ExtractPreciseValue<
input,
InvertPattern<pattern, input>
>;

type Chainable<p, omitted extends string = never> = p &
Expand Down
2 changes: 1 addition & 1 deletion src/types/Pattern.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export interface Matcher<
[symbols.isVariadic]?: boolean;
}

type PatternMatcher<input> = Matcher<input, unknown, any, any>;
export type PatternMatcher<input> = Matcher<input, unknown, any, any>;

// We fall back to `a` if we weren't able to extract anything more precise
export type MatchedValue<a, invpattern> = WithDefault<
Expand Down
52 changes: 52 additions & 0 deletions src/variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as P from './patterns';
import { PatternMatcher } from './types/Pattern';
import { Equal } from './types/helpers';

const tagKey = '_tag';
type tagKey = typeof tagKey;

export type Variant<k, d = undefined> = { [tagKey]: k; value: d };

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

type AnyVariant = Variant<string, unknown>;

type Narrow<variant extends AnyVariant, k extends variant[tagKey]> = Extract<
variant,
Variant<k, {}>
>;

type Constructor<variant extends AnyVariant> = variant extends {
[tagKey]: infer tag;
value: infer value;
}
? Equal<value, undefined> extends true
? () => Variant<tag>
: Equal<value, unknown> extends true
? <t extends value>(value: t) => Variant<tag, t>
: {
(value: value): variant;
<input, const p extends PatternMatcher<input>>(
pattern: p
): VariantPattern<input, tag, p>;
}
: never;

type Impl<variants extends AnyVariant> = {
[variant in variants as variant[tagKey]]: Constructor<variant>;
};

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>]) => ({
[tagKey]: tag,
...(args.length === 0 ? {} : args[0]),
});
},
});
}
162 changes: 162 additions & 0 deletions tests/variants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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(P._), (circle) => Math.PI * circle.value.radius ** 2)
.with(Square(P.select()), ({ sideLength }) => sideLength ** 2)
.with(Rectangle(P.select()), ({ x, y }) => x * y)
.with(Blob(P._), ({ value }) => value)
.exhaustive();

const x = Circle({ radius: 1 });

expect(area(x)).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(P.shape({ radius: P.select('a') })),
b: Circle(P.shape({ 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(P.shape({ sideLength: P.select('a') })),
b: Square(P.shape({ 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(P.shape({ 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();

const x = Just(Circle({ radius: 20 }));

expect(toString(x)).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', value: P.select() }), (x) => 'typeof x: string')
.with(Just({ type: 'u', 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(0) }))).toEqual('Blob 20');
expect(complexMatch(Err('Failed'))).toEqual('Error: Failed');
});
});