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

New Feature: add TaggedUnionType #183

Merged
merged 1 commit into from
Aug 11, 2018
Merged

New Feature: add TaggedUnionType #183

merged 1 commit into from
Aug 11, 2018

Conversation

gcanti
Copy link
Owner

@gcanti gcanti commented Jun 24, 2018

@sledorze I know you are using unionize in order to handle ADTs so I'd like to hear your opinion on this: I'm trying to define some helper functions for tagged unions (and maybe a small library). This is the general form (works with any tagged union)

export type TaggedUnionMember<A extends object, Tag extends keyof A, Value extends A[Tag]> = Extract<
  A,
  { [K in Tag]: Value }
>

export type Omit<A extends object, K extends string | number | symbol> = Pick<A, Exclude<keyof A, K>>

export type TagValue<A, Tag extends keyof A> = A[Tag] & (string | number)

export type Constructors<A extends object, Tag extends keyof A> = {
  [K in TagValue<A, Tag>]: (x: Omit<TaggedUnionMember<A, Tag, K>, Tag>) => A
}

export const getConstructors = <A extends object = never>() => <Tag extends keyof A>(
  tag: Tag,
  values: { [K in TagValue<A, Tag>]: null }
): Constructors<A, Tag> => {
  const r: any = {}
  for (const value in values) {
    r[value] = (x: any) => ({ [tag]: value, ...x })
  }
  return r
}

export type Match<A extends object, Tag extends keyof A> = <R>(
  a: A,
  handlers: { [K in TagValue<A, Tag>]: (x: TaggedUnionMember<A, Tag, K>) => R }
) => R

export const getMatch = <A extends object = never>() => <Tag extends keyof A>(tag: Tag): Match<A, Tag> => {
  return (a, handlers) => (handlers as any)[a[tag]](a)
}

export type Guards<A extends object, Tag extends keyof A> = {
  [K in TagValue<A, Tag>]: (x: A) => x is TaggedUnionMember<A, Tag, K>
}

export const getGuards = <A extends object = never>() => <Tag extends keyof A>(
  tag: Tag,
  values: { [K in TagValue<A, Tag>]: null }
): Guards<A, Tag> => {
  const r: any = {}
  for (const value in values) {
    r[value] = (x: any) => x[tag] === value
  }
  return r
}

Usage

interface A {
  tag: 'A'
  foo: string
}
interface B {
  tag: 'B'
  bar: number
  baz: boolean
}
type C = A | B

const tags = { A: null, B: null }
const constructors = getConstructors<C>()('tag', tags)
const guards = getGuards<C>()('tag', tags)
const match = getMatch<C>()('tag')

assert.deepEqual(constructors.A({ foo: 'foo' }), { tag: 'A', foo: 'foo' })
assert.deepEqual(constructors.B({ bar: 1, baz: true }), { tag: 'B', bar: 1, baz: true })
assert.strictEqual(guards.A({ tag: 'A', foo: 'foo' }), true)
assert.strictEqual(guards.A({ tag: 'B', bar: 1, baz: true }), false)
assert.strictEqual(
  match(
    { tag: 'B', bar: 1, baz: true },
    {
      A: x => x.foo.length,
      B: x => x.bar
    }
  ),
  1
)

I also wrote a version which leverages io-ts tagged unions (note that I need this PR since I'm using T['tag'] in the implementations)

export const getTaggedUnionConstructors = <T extends t.TaggedUnionType<any, any, any>>(
  type: T
): Constructors<t.TypeOf<T>, T['tag']> => {
  const tag = type.tag
  // IMPORTANT    v--- this is an internal API, use it at your own risk
  const index = t.getIndexRecord(type.types)[type.tag]
  const r: any = {}
  for (let i = 0; i < index.length; i++) {
    const value = index[i][0]
    r[String(value)] = (x: any) => ({ [tag]: value, ...x })
  }
  return r
}

export const getTaggedUnionMatch = <T extends t.TaggedUnionType<any, any, any>>(
  type: T
): Match<t.TypeOf<T>, T['tag']> => {
  return (a, handlers) => (handlers as any)[a[type.tag]](a)
}

export const getTaggedUnionGuards = <T extends t.TaggedUnionType<any, any, any>>(
  type: T
): Guards<t.TypeOf<T>, T['tag']> => {
  const tag = type.tag
  // IMPORTANT    v--- this is an internal API, use it at your own risk
  const index = t.getIndexRecord(type.types)[type.tag]
  const r: any = {}
  for (let i = 0; i < index.length; i++) {
    const value = index[i][0]
    r[String(value)] = (x: any) => x[tag] === value
  }
  return r
}

Usage

const AR = t.type({
  tag: t.literal('A'),
  foo: t.string
})

const BR = t.type({
  tag: t.literal('B'),
  bar: t.number,
  baz: t.boolean
})

const CR = t.taggedUnion('tag', [AR, BR])

const taggedConstructors = getTaggedUnionConstructors(CR)
const taggedGuards = getTaggedUnionGuards(CR)
const taggedMatch = getTaggedUnionMatch(CR)

assert.deepEqual(taggedConstructors.A({ foo: 'foo' }), { tag: 'A', foo: 'foo' })
assert.deepEqual(taggedConstructors.B({ bar: 1, baz: true }), { tag: 'B', bar: 1, baz: true })
assert.strictEqual(taggedGuards.A({ tag: 'A', foo: 'foo' }), true)
assert.strictEqual(taggedGuards.A({ tag: 'B', bar: 1, baz: true }), false)
assert.strictEqual(
  taggedMatch(
    { tag: 'B', bar: 1, baz: true },
    {
      A: x => x.foo.length,
      B: x => x.bar
    }
  ),
  1
)

@sledorze
Copy link
Collaborator

sledorze commented Jun 24, 2018

@gcanti that would be a great addition.

As for the usage of the lib, we found out that to ease with developper experience,
we needed to work with 'opaques' types (interfaces).

see this: pelotom/unionize#35

P.S.: 'Extract' with 'union's is quite a powerful tool :)

@gcanti
Copy link
Owner Author

gcanti commented Jun 24, 2018

see this: pelotom/unionize#35

@sledorze sorry, I'm not sure what should I look at.

constructors already gives the correct return type

const x = constructors.A({ foo: 'foo' })
// x has type C as expected

@sledorze
Copy link
Collaborator

@gcanti I m AFK so i ve not tried it.
Does the getTaggedUnionConstructor variant behaves the same?

@gcanti
Copy link
Owner Author

gcanti commented Jun 24, 2018

Does the getTaggedUnionConstructor variant behaves the same?

Yes

const x = taggedConstructors.A({ foo: 'foo' })
/* x has type
t.TypeOfProps<{
    tag: t.LiteralType<"A">;
    foo: t.StringType;
}> | t.TypeOfProps<{
    tag: t.LiteralType<"B">;
    bar: t.NumberType;
    baz: t.BooleanType;
}>
as expected
*/

You can get a neater result if you use alias (as usual)

const AR_ = t.type({
  tag: t.literal('A'),
  foo: t.string
})
interface AR extends t.TypeOf<typeof AR_> {}
const AR = t.alias(AR_)<AR, AR>()

const BR_ = t.type({
  tag: t.literal('B'),
  bar: t.number,
  baz: t.boolean
})
interface BR extends t.TypeOf<typeof BR_> {}
const BR = t.alias(BR_)<BR, BR>()

const CR = t.taggedUnion('tag', [AR, BR])

const x = taggedConstructors.A({ foo: 'foo' })
// x has type AR | BR as expected

@gunzip
Copy link

gunzip commented Jun 25, 2018

that would nice to be able to call taggedMatch from the type itself like the (now gone) good ol union fold:

#30

this would be a great addition

@sledorze
Copy link
Collaborator

sledorze commented Jun 25, 2018

To come back to that matter, we use unionize on the front side but a custom variation of it in the back-end as we have two tags per Commands/Events (CQRS backend).
Those tags are the aggregate type ex: accommodation and the command/event type ex: create.

The impact of that (double) tags is that, for instance, getConstructors would need to be specialised for a certain aggregate type (e.g.: accommodation) so that matching on Event type create will match create event of accommodation aggregates and not create event of flight aggregates.

@gcanti I'm not saying that you should stick to that requirement, it's just feedback from the trenches.

@gcanti gcanti mentioned this pull request Jul 4, 2018
@OliverJAsh
Copy link
Contributor

OliverJAsh commented Jul 4, 2018

@gcanti Does this approach support renaming of tags, like in unionize? I.e. will TypeScript persist a rename of a tag to the type definition and all of its usages? (I ask because I've ran into problems before where this doesn't always happen.)

@gcanti gcanti force-pushed the TaggedUnionType branch from 116eda9 to 40c01f1 Compare July 4, 2018 13:40
@gcanti
Copy link
Owner Author

gcanti commented Jul 4, 2018

@OliverJAsh not sure what you mean, however you can find out by yourself: I just put up a branch (TaggedUnionType-with-lib) containing the lib folder so you can install it by running npm i gcanti/io-ts#TaggedUnionType-with-lib

@OliverJAsh
Copy link
Contributor

Thanks @gcanti. I just tried it. Unfortunately renaming tags doesn't persist.

What I mean by this is if you try to rename the tag in taggedConstructors.B({ bar: 1, baz: true }) from B to C, for example, I would expect all references of the tag to be updated with it.

This is the behaviour I currently rely on in unionize. Before:

image

… and after:

image

@gcanti
Copy link
Owner Author

gcanti commented Jul 5, 2018

@gunzip I'm not going to add anything related to constructors / pattern matching to io-ts (*), however if somebody is willing to publish a library containing getTaggedUnionConstructors, getTaggedUnionMatch (or other similar versions) and needs this PR merged, just let me know.

@OliverJAsh I see, thanks. Looks like it's something related to the refactoring features of TypeScript, not sure we can do anything about it.

(*) Personally I think that custom constructors and switch statements are superior and more flexible despite the boilerplate

@sledorze
Copy link
Collaborator

sledorze commented Jul 5, 2018

@gcanti beware, nested switch in typescript have(had?) an inference issue, loosing some refinements.

@OliverJAsh
Copy link
Contributor

@gcanti Are you thinking of merging this or is it on hold?

@gcanti gcanti merged commit 53a2b00 into master Aug 11, 2018
@gcanti gcanti deleted the TaggedUnionType branch August 11, 2018 09:37
@gcanti
Copy link
Owner Author

gcanti commented Aug 11, 2018

@OliverJAsh released

@gcanti gcanti added this to the 1.3.0 milestone Aug 13, 2018
@OliverJAsh
Copy link
Contributor

I'm trying to define some helper functions for tagged unions (and maybe a small library)

@gcanti Do you have any intention to publish your helpers in a separate library?

@gcanti
Copy link
Owner Author

gcanti commented Sep 11, 2018

@OliverJAsh No, I don't

if somebody is willing to publish a library containing getTaggedUnionConstructors, getTaggedUnionMatch (or other similar versions) and needs this PR merged, just let me know

feel free to take/modify/extend my snippets above

@joshburgess
Copy link

@gcanti Are your snippets here in this PR your most recent/refined attempts? I saw some places where you had others. Just wondering.

@gcanti
Copy link
Owner Author

gcanti commented Sep 12, 2018

@joshburgess yes they are, off the top of my head the only thing that I would change is

-export type TagValue<A, Tag extends keyof A> = A[Tag] & (string | number)
+export type TagValue<A, Tag extends keyof A> = A[Tag] & PropertyKey

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants