Skip to content

Commit

Permalink
feat: support additional tag property names (#264)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonkuhrt authored Feb 25, 2023
1 parent a43bccb commit 9f17206
Show file tree
Hide file tree
Showing 6 changed files with 202 additions and 18 deletions.
7 changes: 6 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@
// Needed when working with .mts/.cts where a lone e.g. <T> is not allowed
"@typescript-eslint/no-unnecessary-type-constraint": "off",
// Useful for organizing Types
"@typescript-eslint/no-namespace": "off"
"@typescript-eslint/no-namespace": "off",
// Turn training wheels off. When we want these we want these.
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-explicit-any": "off"
}
}
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -832,9 +832,20 @@ Use `.match` to dispatch code execution based on data patterns. Among other thin
- Chain tag or data (or both) matchers
- Finish with `.done()` to statically verify variant exhaustiveness or `.else(...)` if you want to specify a fallback value.

You can see some examples in action [here](./examples/Match.ts).

### Tag Matchers

Tag Matchers simply branch based on the variant's tag (`_tag`). You call `.done()` to perform the exhaustiveness check. If you can't call this (because of static type error) then your pattern matching is not exhaustive. This catches bugs!
Tag Matchers simply branch based on the variant's tag property. The tag property can be any of the following. The first one found in the following order is used. so for example if both `_kind` and `type` are present then `_kind` is considered the tag property.

- `__typename` (this is helpful if you're working with GraphQL unions)
- `_tag`
- `_type`
- `_kind`
- `type`
- `kind`

You call `.done()` to perform the exhaustiveness check. If you can't call this (because of static type error) then your pattern matching is not exhaustive. This catches bugs!

```ts
const result = Alge.match(shape)
Expand Down
72 changes: 72 additions & 0 deletions examples/Match.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Alge } from '../src/index.js'

/**
* You can work with
*/

type Fruit = `apple` | `banana` | `orange`

const fruits = [`apple`, `banana`, `orange`] satisfies [Fruit, ...Fruit[]]

const fruit = fruits[Math.floor(Math.random() * fruits.length)]!

const _fruitMatchResult = Alge.match(fruit)
.apple(() => `Do something with apple` as const)
.banana(() => `Do something with banana` as const)
.orange(() => `Do something with orange` as const)

/**
* You can work with different names for the discriminant property. See docs for all options. Here are a few examples:
*/

/**
* Example with `type`
*/

type Shape =
| { type: `circle`; radius: number }
| { type: `square`; size: number }
| { type: `rectangle`; width: number; height: number }

const shapes = [
{ type: `circle`, radius: 10 },
{ type: `square`, size: 20 },
{ type: `rectangle`, width: 30, height: 40 },
] satisfies [Shape, ...Shape[]]

const shape = shapes[Math.floor(Math.random() * shapes.length)]!

const _shapeMatchResult = Alge.match(shape)
.circle(() => `Do something with circle` as const)
.square(() => `Do something with square` as const)
.rectangle(() => `Do something with rectangle` as const)
.done()

/**
* Example with `kind`
*/

type Hero =
| { kind: 'superman' }
| { kind: 'batman' }
| { kind: 'spiderman' }
| { kind: 'wonderWoman' }
| { kind: 'flash' }

const heroes = [
{ kind: `superman` },
{ kind: `batman` },
{ kind: `spiderman` },
{ kind: `wonderWoman` },
{ kind: `flash` },
] satisfies [Hero, ...Hero[]]

const hero = heroes[Math.floor(Math.random() * heroes.length)]!

const _heroMatchResult = Alge.match(hero)
.batman(() => `Do something with batman` as const)
.flash(() => `Do something with flash` as const)
.spiderman(() => `Do something with spiderman` as const)
.superman(() => `Do something with superman` as const)
.wonderWoman(() => `Do something with wonderWoman` as const)
.done()
33 changes: 19 additions & 14 deletions src/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ Terminology:

import type { OmitTag } from './core/types.js'
import { inspect } from './lib/utils.js'
import type { SomeRecord } from './record/types/controller.js'
import type { GetTag, GetTagProperty, SomeRecord, SomeTaggedRecord } from './record/types/controller.js'
import { getTag } from './record/types/controller.js'
import isMatch from 'lodash.ismatch'
export type SomeTag = string

Expand All @@ -36,12 +37,12 @@ export interface DataMatcherDefinition {
// prettier-ignore
export function match<Tag extends SomeTag>(tag: Tag): ChainTagPreMatcher<Tag, never>
// prettier-ignore
export function match<AlgebraicDataType extends SomeRecord>(algebraicDataType: AlgebraicDataType): ChainPreMatcher<AlgebraicDataType, never>
export function match<AlgebraicDataType extends SomeTaggedRecord>(algebraicDataType: AlgebraicDataType): ChainPreMatcher<AlgebraicDataType, never>
// prettier-ignore
// eslint-disable-next-line prefer-arrow/prefer-arrow-functions
export function match <AlgebraicDataTypeOrTag extends SomeTag|SomeRecord>(input: AlgebraicDataTypeOrTag):
AlgebraicDataTypeOrTag extends string ? ChainTagPreMatcher<AlgebraicDataTypeOrTag, never> :
AlgebraicDataTypeOrTag extends SomeRecord ? ChainPreMatcher<AlgebraicDataTypeOrTag, never> :
export function match <ADTOrTag extends SomeTag | SomeTaggedRecord>(input: ADTOrTag):
ADTOrTag extends string ? ChainTagPreMatcher<ADTOrTag, never> :
ADTOrTag extends SomeTaggedRecord ? ChainPreMatcher<ADTOrTag, never> :
never {

const elseBranch: { defined: boolean; value: unknown | ((data: object) => unknown) } = {
Expand All @@ -53,7 +54,7 @@ export function match <AlgebraicDataTypeOrTag extends SomeTag|SomeRecord>(input:

const execute = () => {
for (const matcher of matcherStack) {
if (typeof input === `string` && matcher.tag === input || typeof input === `object` && matcher.tag === input._tag) {
if (typeof input === `string` && matcher.tag === input || typeof input === `object` && matcher.tag === getTag(input)) {
if (matcher._tag === `DataMatcherDefinition`) {
if (isMatch(input as SomeRecord, matcher.dataPattern)) {
return matcher.handler(input as SomeRecord)
Expand Down Expand Up @@ -132,11 +133,13 @@ export function match <AlgebraicDataTypeOrTag extends SomeTag|SomeRecord>(input:
return proxy as any
}

type PickRecordHavingTag<Tag extends string, ADT extends SomeRecord> = ADT extends { _tag: Tag } ? ADT : never
type PickRecordHavingTag<Tag extends string, ADT extends SomeTaggedRecord> = ADT extends { _tag: Tag }
? ADT
: never

//prettier-ignore
type ChainPreMatcher<ADT extends SomeRecord, Result> = {
[Tag in ADT['_tag']]:
type ChainPreMatcher<ADT extends SomeTaggedRecord, Result> = {
[Tag in GetTag<ADT>]:
(<ThisResult extends unknown, Pattern extends Partial<OmitTag<PickRecordHavingTag<Tag, ADT>>>>(dataPattern: Pattern, handler: (data: Pattern & PickRecordHavingTag<Tag, ADT>, test:Pattern) => ThisResult) => ChainPostMatcher<ADT, never, ThisResult | Result>) &
(<ThisResult extends unknown>(handler: (data: PickRecordHavingTag<Tag, ADT>) => ThisResult) => ChainPostMatcher<ADT, Tag, ThisResult | Result>)
}
Expand All @@ -147,15 +150,15 @@ type ChainPreMatcher<ADT extends SomeRecord, Result> = {
* never be called at runtime
*/
//prettier-ignore
type ChainPostMatcher<ADT extends SomeRecord, TagsPreviouslyMatched extends string, Result> = {
[Tag in Exclude<ADT['_tag'], TagsPreviouslyMatched>]:
type ChainPostMatcher<ADT extends SomeTaggedRecord, TagsPreviouslyMatched extends string, Result> = {
[Tag in Exclude<GetTag<ADT>, TagsPreviouslyMatched>]:
(
(<ThisResult extends unknown, Pattern extends Partial<OmitTag<PickRecordHavingTag<Tag, ADT>>>>(dataPattern: Pattern, handler: (data: Pattern & PickRecordHavingTag<Tag, ADT>) => ThisResult) => ChainPostMatcher<ADT, TagsPreviouslyMatched, '__init__' extends Result ? ThisResult : ThisResult | Result>) &
(<ThisResult extends unknown>(handler: (data: PickRecordHavingTag<Tag, ADT>) => ThisResult) => ChainPostMatcher<ADT, Tag|TagsPreviouslyMatched, ThisResult | Result>)
)
// ^[1] ^[1]
} & (
Exclude<ADT['_tag'], TagsPreviouslyMatched> extends never ? {
Exclude<GetTag<ADT>, TagsPreviouslyMatched> extends never ? {
done: () => Result
} : {
else: <ThisResult extends unknown>(value: ThisResult | ((data: ExcludeByTag<ADT, TagsPreviouslyMatched>) => ThisResult)) => Result | ThisResult
Expand All @@ -182,6 +185,8 @@ type ChainTagPostMatcher<Tags extends SomeTag, TagsPreviouslyMatched extends str
}
)

type ExcludeByTag<Record extends SomeRecord, Tag extends string> = Record extends { _tag: Tag }
// prettier-ignore
type ExcludeByTag<TaggedRecord extends SomeTaggedRecord, Tag extends string> =
TaggedRecord extends { [k in GetTagProperty<TaggedRecord>]: Tag }
? never
: Record
: TaggedRecord
44 changes: 42 additions & 2 deletions src/record/types/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,48 @@ import type {
import type { SomeStoredRecord, StoredRecord } from './StoredRecord.js'
import type { Any } from 'ts-toolbelt'

export type SomeRecord = {
_tag: string
const tagPropertyNames = [`__typename`, `_tag`, `_type`, `_kind`, `type`, `kind`]

export const getTagProperty = <R extends SomeTaggedRecord>(taggedRecord: R): GetTagProperty<R> => {
return tagPropertyNames.find((propertyName) => propertyName in taggedRecord) as GetTagProperty<R>
}

export const getTag = <R extends SomeTaggedRecord>(taggedRecord: R): GetTagProperty<R> => {
const tagProperty = getTagProperty(taggedRecord)
// @ts-expect-error - should be there or fallback to undefined.
return taggedRecord[tagProperty] as GetTag<R>
}

// prettier-ignore
export type GetTag<ADT extends SomeTaggedRecord> =
ADT extends { __typename : string } ? ADT['__typename'] :
ADT extends { _tag : string } ? ADT['_tag'] :
ADT extends { _type : string } ? ADT['_type'] :
ADT extends { _kind : string } ? ADT['_kind'] :
ADT extends { type : string } ? ADT['type'] :
ADT extends { kind : string } ? ADT['kind'] :
never

// prettier-ignore
export type GetTagProperty<TaggedRecord extends SomeTaggedRecord> =
TaggedRecord extends { __typename: string } ? '__typename' :
TaggedRecord extends { _tag : string } ? '_tag' :
TaggedRecord extends { _type : string } ? '_tag' :
TaggedRecord extends { _kind : string } ? '_tag' :
TaggedRecord extends { type : string } ? '_tag' :
TaggedRecord extends { kind : string } ? '_tag' :
never

export type SomeTaggedRecord =
| SomeRecord<'__typename'>
| SomeRecord<'_tag'>
| SomeRecord<'_type'>
| SomeRecord<'_kind'>
| SomeRecord<'type'>
| SomeRecord<'kind'>

export type SomeRecord<TagPropertyName extends string = '_tag'> = {
[PropertyName in TagPropertyName]: string
}

export type SomeRecordInternal = {
Expand Down
51 changes: 51 additions & 0 deletions tests/match/tag-match.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,57 @@ type Tag = A | B
const tagA = 'A' as Tag
const tagB = 'B' as Tag

describe('accepted tag properties', () => {
it(`_tag`, () => {
const adt = Math.random() > 0.5 ? { _tag: 'A' as const } : { _tag: 'B' as const }
const builder = Alge.match(adt)
expect(typeof builder.A).toBe(`function`)
expect(typeof builder.B).toBe(`function`)
expectType<(handler: () => unknown) => any>(builder.A)
expectType<(handler: () => unknown) => any>(builder.B)
})
it(`__typename`, () => {
const adt = Math.random() > 0.5 ? { __typename: 'A' as const } : { __typename: 'B' as const }
const builder = Alge.match(adt)
expect(typeof builder.A).toBe(`function`)
expect(typeof builder.B).toBe(`function`)
expectType<(handler: () => unknown) => any>(builder.A)
expectType<(handler: () => unknown) => any>(builder.B)
})
it(`_type`, () => {
const adt = Math.random() > 0.5 ? { _type: 'A' as const } : { _type: 'B' as const }
const builder = Alge.match(adt)
expect(typeof builder.A).toBe(`function`)
expect(typeof builder.B).toBe(`function`)
expectType<(handler: () => unknown) => any>(builder.A)
expectType<(handler: () => unknown) => any>(builder.B)
})
it(`_kind`, () => {
const adt = Math.random() > 0.5 ? { _kind: 'A' as const } : { _kind: 'B' as const }
const builder = Alge.match(adt)
expect(typeof builder.A).toBe(`function`)
expect(typeof builder.B).toBe(`function`)
expectType<(handler: () => unknown) => any>(builder.A)
expectType<(handler: () => unknown) => any>(builder.B)
})
it(`type`, () => {
const adt = Math.random() > 0.5 ? { type: 'A' as const } : { type: 'B' as const }
const builder = Alge.match(adt)
expect(typeof builder.A).toBe(`function`)
expect(typeof builder.B).toBe(`function`)
expectType<(handler: () => unknown) => any>(builder.A)
expectType<(handler: () => unknown) => any>(builder.B)
})
it(`kind`, () => {
const adt = Math.random() > 0.5 ? { kind: 'A' as const } : { kind: 'B' as const }
const builder = Alge.match(adt)
expect(typeof builder.A).toBe(`function`)
expect(typeof builder.B).toBe(`function`)
expectType<(handler: () => unknown) => any>(builder.A)
expectType<(handler: () => unknown) => any>(builder.B)
})
})

it(`returns a Match Builder, an object with methods named according to the possible tags`, () => {
const builder = Alge.match(tagA)
expect(typeof builder.A).toBe(`function`)
Expand Down

0 comments on commit 9f17206

Please sign in to comment.