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

Add Variant types #45

wants to merge 2 commits into from

Conversation

gvergnaud
Copy link
Owner

@gvergnaud gvergnaud commented Sep 6, 2021

Motivation

TS-Pattern's goal is to make it possible to pattern-match on any kind of data structure, which means you can start using it in your project without having to change your internal data format.

That said, discriminated union types are a bit verbose when compared to data constructors that exist on languages like OCaml, Haskell, ReScript, etc:

In TS

type Shape =
    |  { tag: "Circle", value: { radius: number } } 
    |  { tag: "Rectangle", value: { width: number, height: number } }
    |  { tag: "Square", value: { size: number } }


const area = (shape: Shape) => 
    match(shape)
        .with({ tag: "Circle", value: select() }, ({ radius }) =>  Math.PI * radius ** 2)
        .with({ tag: "Rectangle", value: select() }, ({ width, height }) => width * height)
        .with({ tag: "Square", value: select() }, ({ size }) => size ** 2)
        .exhaustive()

area({ tag: "Circle", value: { radius: 20 } })
// => 1256.6370614359173

We are repeating { tag: "<Name>", value: <Value> } a lot. In ReScript in comparison, the same code feels let verbose:

type shape =
    | Circle({ radius: float })
    | Rectangle({ width: float, height: float })
    | Square({ size: float } )


let area = (shape: shape) => 
    switch shape {
        | Circle({ radius }) =>  Js.Math._PI *. radius *. radius
        | Rectangle({ width, height }) => width *. height
        | Square({ size }) => size *. size
    }

area(Circle({ radius: 20. }))
// => 1256.6370614359173

It looks a lot better because each variant in the union has a matching constructor: a function of same name that takes the variant's content and returns a value of this type.

Solution

Here is a proposal to add an easier way to define Variants and match on them with TS-Pattern:

import { match, select, Variant, implementVariants }  from 'ts-pattern'

type Shape =
    |  Variant<"Circle", { radius: number }>
    |  Variant<"Rectangle", { width: number, height: number }>
    |  Variant<"Square", { size: number }>

const  { Circle, Rectangle, Square } = implementVariants<Shape>()

const area = (shape: Shape) => 
    match(shape)
        .with(Circle(select()), ({ radius }) =>  Math.PI * radius ** 2)
        .with(Rectangle(select()), ({ width, height }) => width * height)
        .with(Square(select()), ({ size }) => size ** 2)
        .exhaustive()

area(Circle({ radius: 20 }))
// => 1256.6370614359173

Variant<Tag, Content> is a Generic returning a plain object type with this shape: { tag: Tag, value: Content }.

implementVariants takes a union type parameter and generate the constructors for each variant of this union. Constructors are simple functions taking the content, and wrapping them in an object with the appropriate tag.

The objects constructed using a Constructor function have nothing special. They are plain object of shape { tag, value }, they don't have any method and don't inherit from any class. They can be serialised and constructed by hand.

Constructors can also be used to construct a pattern. In this case the returned value will be of type VariantPattern<Tag, Content>, which is an alias for Variant<Tag, Pattern<Content>>. This means you can match with nested constructors as well:

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

const { Just, Nothing } = implementVariants<Maybe<unknown>>();

const toString = (maybeShape: Maybe<Shape>) =>
      match(maybeShape)
        .with(
           Nothing(),
           () => 'Nothing'
        )
        .with(
          Just(Circle({ radius: select() })),
          (radius) => `Just Circle { radius: ${radius} }`
        )
        .with(
          Just(Square(select())),
          ({ sideLength }) => `Just Square sideLength: ${sideLength}`
        )
        .with(
          Just(Rectangle(select())),
          ({ x, y }) => `Just Rectangle { x: ${x}, y: ${y} }`
        )
        .with(
          Just(Blob(select())),
          (area) => `Just Blob { area: ${area} }`
        )
        .exhaustive();
        
        
toString(Just(Circle({ radius: 20 })))
// => `Just Circle { radius: 20 }`

toString(Nothing())
// => `Nothing`

Using Variants with ts-pattern would be entirely optional! match will keep working on regular values. This isn't a breaking change and implementing this new feature actually doens't require changing the implementation of match.

Inspiration

This addition is in large parts inspired by https://github.com/practical-fp/union-types. Using a typed Proxy to generate constructors from a union type is a very good idea. Unfortunately the values created with union-types aren't compatible with ts-pattern, this PR tries to bridge this gap

@gvergnaud gvergnaud self-assigned this Sep 6, 2021
@gvergnaud gvergnaud marked this pull request as draft September 6, 2021 11:56
@gvergnaud gvergnaud marked this pull request as ready for review September 11, 2021 14:58
@FredericEspiau
Copy link

Wonderful idea but I feel like it's out of scope from what ts-pattern is about, which is, as you said it, to work on all data types.

I feel like a better way to do this would be to help people create bridges between ts-pattern and the type library of their choice.

Example: you want to use zod ? There is a plugin for that. You want to use https://github.com/paarthenon/variant ? No problem, someone made a plugin for that.

This way you can focus on having the best pattern matching library and delivering a way for people to extend it at their will :)

@steveadams
Copy link

@FredericEspiau I like what you're suggesting in principle. Can you roughly outline how you'd envision a plugin for zod which accomplishes what this PR does? I'm not able to piece it together in my mind, perhaps because I haven't used zod enough.

What I'm not clear on is how you'd make it so ts-pattern can reliably pick out a variant without a plugin monkey-patching both ts-pattern and whatever library you're trying to bridge to.

I do like this Variants implementation, though I'd be hesitant to adopt it throughout my code. For example, for my work I deal with a lot of objects representing domains (https://instantdomainsearch.com) and I need to track our internal understanding of their 'types' by inspecting various properties.

At the moment I can pattern match and get great results, but it's very verbose at times. It could be much nicer if far upstream in my code I could pattern match as the domains come from our back-end and then create variants from them there. Anywhere further downstream I could match on simpler but still reliable expressions.

Having said that, I'd be using a ts-pattern-specific structure extensively throughout the code base. I really like the library, but I'm hesitant to do that – does that make sense? Am I misunderstanding how this would work in the real world?

@gvergnaud
Copy link
Owner Author

gvergnaud commented Oct 31, 2021

👋

Thanks for reading the PR and giving your opinion, it's great to have some feedback on this stuff :)

Since TS-Pattern uses predicates to determine if a pattern matches or not, you can already use it with your library of choice pretty easily. For example, you can write a matchZodSchema function in user land:

const matchZodSchema = <S extends z.Schema<any, any>>(schema: S) =>
  when((obj: unknown): obj is z.infer<S> => schema.safeParse(obj).success);

and then use it like so:

const userSchema = z.object({
  name: z.string()
});

const getUserName = (u: unknown) =>
  match(u)
    .with(matchSchema(userSchema), ({ name }) => name)
    .otherwise(() => "Anonymous");

https://codesandbox.io/s/example-ts-pattern-zod-h0z2q

I'm not sure we need a "plugin system" for that. A predicate function is enough (but admittedly those predicates can be hard to type correctly).

Having said that, I'd be using a ts-pattern-specific structure extensively throughout the code base. I really like the library, but I'm hesitant to do that – does that make sense? Am I misunderstanding how this would work in the real world?

I understand the wariness about using TS-Pattern specific data constructors throughout a big codebase, that's why I would like those variants to not be specific to the library. My goal is to make it easier to write discriminated union types by automating the creation of constructors via the implementVariants function, but those constructors are just simple function returning a value of a type you defined (using Variant<YourType>). The instances aren't special either, they are just plain, serialisable JS objects, which makes them usable in any context.

I decided to open this PR because I feel like most of the variant libraries out there are too complex and not composable enough. From my exploration, it looks like most of them return class instances with methods on them to match on their different cases (something like maybeUser.match({ some: user => {}, none: () => {} })), which means you can't match on a nested structure, and you can't pass them to parts of your codebase that aren't using the library. That's why I started thinking about a simpler variant library that would essentially be syntactic sugar to define regular discriminated union types and that would play well with ts-pattern.

@steveadams
Copy link

@gvergnaud Thank you for explaining this for me - I understand much better now. I can see that a Variant is less intrusive of a convention than I thought. I definitely misunderstood some parts of how this would be used. By reading through the test cases more closely I think I clued into a few things better as well.

The more I look at it and consider how I'd use it in my own work, I like it quite a bit. Our use case would be the one I mentioned with creating domain variants and then leveraging them as far upstream in our logic as possible such that we can gradually avoid writing code which works on any type of domain yet is intended for a specific type of domain. It creates an astounding surface area for bugs.

This is a sore spot in our code base with many possible solutions, but it's actually why we adopted ts-pattern. It's a large component of our solution, and variants seem to fit directly in line with our needs.

What are the issues you currently see with the PR? Do you still want to improve upon it, or are there blocking issues?

@cevr
Copy link

cevr commented Jan 6, 2024

Is there interest in making this a sub import so that it doesn't affect bundle size? something like ts-pattern/variants. This would be a great addition.

Or, is this easily done is userland nowadays?

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.

None yet

4 participants