diff --git a/src/index.ts b/src/index.ts index 1276264b..78e15ca9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/patterns.ts b/src/patterns.ts index 24bdd351..1b3eb3b3 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -78,11 +78,14 @@ export type unstable_Matcher< * const userPattern = { name: P.stringĀ } * type User = P.infer */ -export type infer

> = InvertPattern; +export type infer> = InvertPattern< + pattern, + unknown +>; -export type narrow> = ExtractPreciseValue< - i, - InvertPattern +export type narrow> = ExtractPreciseValue< + input, + InvertPattern >; type Chainable = p & diff --git a/src/types/Pattern.ts b/src/types/Pattern.ts index 8e18d8fe..3fb5ec9b 100644 --- a/src/types/Pattern.ts +++ b/src/types/Pattern.ts @@ -67,7 +67,7 @@ export interface Matcher< [symbols.isVariadic]?: boolean; } -type PatternMatcher = Matcher; +export type PatternMatcher = Matcher; // We fall back to `a` if we weren't able to extract anything more precise export type MatchedValue = WithDefault< diff --git a/src/variants.ts b/src/variants.ts new file mode 100644 index 00000000..0fab2969 --- /dev/null +++ b/src/variants.ts @@ -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 = { [tagKey]: k; value: d }; + +/** + * VariantPatterns can be used to match a Variant in a + * `match` expression. + */ +type VariantPattern = { [tagKey]: k; value: p }; + +type AnyVariant = Variant; + +type Narrow = Extract< + variant, + Variant +>; + +type Constructor = variant extends { + [tagKey]: infer tag; + value: infer value; +} + ? Equal extends true + ? () => Variant + : Equal extends true + ? (value: t) => Variant + : { + (value: value): variant; + >( + pattern: p + ): VariantPattern; + } + : never; + +type Impl = { + [variant in variants as variant[tagKey]]: Constructor; +}; + +export function implementVariants(): Impl { + return new Proxy({} as Impl, { + get: >(_: Impl, tag: k) => { + return (...args: [value?: Narrow]) => ({ + [tagKey]: tag, + ...(args.length === 0 ? {} : args[0]), + }); + }, + }); +} diff --git a/tests/variants.test.ts b/tests/variants.test.ts new file mode 100644 index 00000000..1bc6dad6 --- /dev/null +++ b/tests/variants.test.ts @@ -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 = Variant<'Just', T> | Variant<'Nothing'>; + +const { Just, Nothing } = implementVariants>(); +const { Circle, Square, Rectangle, Blob } = implementVariants(); + +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) => + 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 = Variant<'Success', A> | Variant<'Err', E>; + + const { Success, Err } = implementVariants>(); + + type SomeRes = Result; + + const x = true ? Success({ hello: 'coucou' }) : Err('lol'); + + const y: SomeRes = x; + + const complexMatch = (x: Result) => { + 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'); + }); +});