pfgray / ts-adt

Generate Algebraic Data Types and pattern matchers
MIT License
314 stars 13 forks source link

Provide a function to generate data constructors? #2

Open dougflip opened 4 years ago

dougflip commented 4 years ago

Hi @pfgray - I just stumbled upon this lib and it is really neat! I was wondering if you have a way to hide the _type property from calling code? Going with the Option example from the readme if I wanted to create a some value I can write

const result: Option<string> = { _type: "some", value: "result" };

but it seems like it might be useful to have a way to write

const result: Option<string> = some({ value: "result" });

Maybe the library could provide a function that generates these for you? Curious if you have any thoughts on this? Thanks again for sharing this code!

pfgray commented 4 years ago

Yes, I've thought about this quite a bit, and unfortunately, haven't come up with something I'm really happy with. I'd really like to come up with a solution for this, though, since this is the thing I most miss from other languages!

I typically do exactly what you said and build "smart constructors" for each type:

export type These<L, R> = ADT<{
    left: { left: L },
    right: { right: R },
    both: { left: L, right: R }
  }>

export const left = <L>(left: L): These<L, never> => ({_type: 'left', left})
export const right = <R>(right: R): These<never, R> => ({_type: 'right', right})
export const both = <L, R>(left: L, right: R): These<L, R> => ({_type: 'both', left, right})

These allow me to fine-tune the parameters and return types to my liking (fixing the type on the super type for better inference, supplying never, etc)

A brain dump of my thoughts:

ADT builds a type, not a value. Unfortunately, you can't build both a type (These in our example) and a value (functions that construct those values) with the same function. io-ts accomplishes this by making you build a value first, and then using typeof to extract a type. This is probably the most promising route, but I'm unsure of how to persist generics when using typeof, since all types need to be resolved at that point.

dougflip commented 4 years ago

@pfgray that makes sense! Ya, I was wondering exactly how this could be done in TS. The idea of fine-tuning is a great point too - it might end up that generated constructors would be too generic in the end anyway. Feel free to close this, I appreciate the response! As I learn more about Typescript if I come up with anything I'll be sure to share it.

tgelu commented 4 years ago

@pfgray first of all thank you for this package! I miss proper adts in ts.

Regarding the generics issue in the io-ts approach: I think they have an example where they show how they do it https://github.com/gcanti/io-ts/blob/master/README.md#generic-types

m-bock commented 3 years ago

I was just about to open an issue about this topic. And see, there's only one open and it's exactly about this. So, here's my take on the problem:

Basically we want to reduce some boilerplate for a common problem. As already stated there is no way to go from a type to a value in TypeScript. The other way around is possible so it's worth investigating. However, this has also been mentioned, it's getting problematic with generic type arguments. @tgelu links to an example from io-ts that's about generics. But I think It would not solve the problem he have here. As the type can only be derived after the function is fully applied. We'd have to get a generic type via typeof someValue.

const foo = <T>() => ({
  _tag: "cup",
  of: {} as T,
});

How do we get the type Foo<T> = { _tag: "cup", of: T} from it? As far as I know, this is not possible.

Here's a try:

type Foo = ReturnType<typeof foo> 

resolves to:

type Foo = { _tag: string; of: unknown; }

So I think there is no way around a certain amount of repetition. An I think that's kind of ok, to consider the type as the source of truth and some values that have to match with it.

It has also been noted that any generated constructors may bee to generic in the end. Sometimes you want to have positional arguments, sometimes a record, sometimes a mix, and sometimes you even want to have some setup code inside the constructor.

Ok, let's manually write some constructors that have those custom properties:

I find it useful to define the ADT in two steps. By this, you can reference the non-union definitions in your constructors.

type FatalError<Id> = { atFile: string; id: Id };
type HorribleError = { count: number };

type Error<Id> = ADT<{
  fatalError: FatalError<Id>;
  horribleError: HorribleError;
}>;

And here are the hand crafted constructors:

const error = <Id>() => ({
  // position example
  fatalError: (
    atFile: FatalError<Id>["atFile"],
    id: FatalError<Id>["id"]
  ): Error<Id> => ({
    _type: "fatalError",
    atFile,
    id,
  }),
  // record exmaple
  horribleError: (opts: HorribleError): Error<Id> => ({
    _type: "horribleError",
    ...opts,
  }),
});

How can we reduce a little boilerplate here? That's how I would like to write it:

onst error_ = <Id>() =>
  makeConstructors<Error<Id>>({
    // position example
    fatalError: (
      atFile: FatalError<Id>["atFile"],
      id: FatalError<Id>["id"]
    ) => ({
      atFile,
      id,
    }),
    // record exmaple
    horribleError: (opts: HorribleError) => opts,
  });

Note that the trivial case (horribleError) is very simple now.

And the implementation of makeConstructors:

const makeConstructors = <Cases extends Record<string, {}>>() => <
  Ctors extends Record<keyof Cases, (...args: any[]) => any>
>(
  ctors: Ctors
): { [key in keyof Ctors]: (...args: Parameters<Ctors[key]>) => ADT<Cases> } =>
  pipe(
    ctors,
    record.mapWithIndex((_type, f: any) =>
      flow(f, (rec: any) => ({ ...rec, _type } as ADT<Cases>))
    )
  ) as any;

It basically just composes the functions with other ones that add the correct tag to the result. And it attaches the correct union type as the return type, which is nice for error messages. (We need the any casting as fp-ts works on homogeneous records. there may be a bit more accurate types, though.)

the derived type of the constructors is then:

const error: <Id>() => {
    fatalError: (atFile: string, id: Id) => ADT<Errors<Id>>;
    horribleError: (opts: HorribleError) => ADT<Errors<Id>>;
}

And it seems to work:

console.log(error().horribleError({ count: 2 }));
// { count: 2, _type: 'horribleError' }

What do you think? Would this be a viable way to simplify constructor creation?

anthonyjoeseph commented 3 years ago

I think this is a great idea! Here's my attempt. It's not ideal - a value-level keys must be passed in (unless we used something like ts-transformer-keys). It's not pretty, but it works

Spec:

import { ADT, constructors } from 'ts-adt'

type Xor<A, B> = ADT<{
  nothing: {};
  left: { value: A };
  right: { value: B };
}>;

const xorCtors = <A, B>() => constructors<Xor<A, B>>()(['nothing', 'left', 'right'])

const { nothing, left, right } = xorCtors<number, string>()

const n: Xor<never, never> = nothing()
const l: Xor<number, never> = left({ value: 3 })
const r: Xor<never, string> = right({ value: 'text' })

Implementation:

import * as S from 'fp-ts/Semigroup'
import * as R from 'fp-ts/Record'
import * as A from 'fp-ts/ReadonlyArray'

const makeConstructors = <TagName extends string>(
  tagName: TagName
) => <ADT extends { [tag in TagName]: string }>() => <Tags extends ADT[TagName]>(
  tags: readonly Tags[]
) => R.fromFoldableMap(
  S.last<unknown>(),
  A.Foldable
)
(
  tags,
  (tag) => [
    tag,
    (args: undefined | Omit<Extract<ADT, { [t in TagName]: Tags }>, TagName>) =>
      args ? { [tagName]: tag, ...args } : { [tagName]: tag }
  ]
) as {
  [key in Tags]:
    keyof Omit<Extract<ADT, { [t in TagName]: key }>, TagName> extends never
      ? () => Extract<ADT, { [t in TagName]: key }>
      : (args: Omit<Extract<ADT, { [t in TagName]: key }>, TagName>) =>
        Extract<ADT, { [t in TagName]: key }>
}

const constructors = makeConstructors('_type')

I'd be willing to make a PR for this w/ tests if anyone's interested

anthonyjoeseph commented 3 years ago

[Re: building both a type and a value with the same function] I'm unsure of how to persist generics when using typeof, since all types need to be resolved at that point.

There's a proposal to Typescript that would allow this. It's an interesting read. Hopefully the feature is added soon.

Fwiw I agree that this would be the best solution. Afaict this would turn ts-adt into a sort of generic version of unionize.

It looks like it might not be possible for a little while, but I understand if there's more energy behind holding out for an ideal solution than a compromise

anthonyjoeseph commented 3 years ago

practical-fp/union-types has a clever solution to this problem using a Proxy object