gcanti / flow-static-land

[DEPRECATED, please check out fp-ts] Implementation of common algebraic types in JavaScript + Flow
MIT License
408 stars 22 forks source link

What is this pattern called? #20

Closed jsonnull closed 7 years ago

jsonnull commented 8 years ago

Hello, I'm trying to figure out what's going on here.

class IsSignal {}

export type SignalV<A> = {
  subscribe(subscriber: (a: A) => any): void,
  get(): A,
  set(a: A): void
};

export type Signal<A> = HKT<IsSignal, A>;

export function inj<A>(a: SignalV<A>): Signal<A> {
  return ((a: any): Signal<A>)
}

export function prj<A>(fa: Signal<A>): SignalV<A> {
  return ((fa: any): SignalV<A>)
}

I'll attempt to break down what's going on here based on my current understanding.

I'm assuming the separate Signal and SignalV types as well as the need for inj and prj functions are all code acrobatics to get around Flow's limitations for the purposes of the typechecking involved. You're faking polymorphism using variant types because Flow's type system does not allow polymorphic types that are not classes of the same structural shape. In the case of a call to inj the type Signal is present on the value returned, and vice versa for prj. Is this correct?

It seems like flipping between variants like this is a lot of mental complexity where a more direct solution could be possible. Would you care to comment on whether it might be possible to make this less boilerplate-y for creating new types like this?

Finally, is there a reason why map, of, etc can't be static members of the Signal class? It seems like there are really multiple things in play here: a Signal type which is used to characterize what these instances of map, of, and so on are valid for, while SignalV represents the actual data structure that they work for.

I'm trying to find a solution that lets one implement these patterns as non-members without having to declare variants for each class, I'll let you know if I find anything in the meantime.

gcanti commented 8 years ago

This is the pattern:

HKT represents a generic unary type constructor (kind * -> *)

class HKT<F, A> {} // <= `F` and `A` are phantom types

The recipe

We need

FV<A> is the type at runtime (the V means "value" but we come up with a better name). F<A> exists only for Flow. inj and prj are basically identity functions and could be even stripped out by a babel plugin when producing the build (https://github.com/gcanti/flow-static-land/issues/18)

Examples

Example 1: Maybe

class IsMaybe {} // <= nominal type, not exported

export type MaybeV<A> = ?A; // <= handy type alias

export type Maybe<A> = HKT<IsMaybe, A>; // <= main type alias

export function inj<A>(a: MaybeV<A>): Maybe<A> { // <= inj
  return ((a: any): Maybe<A>)
}

export function prj<A>(fa: Maybe<A>): MaybeV<A> { // <= prj
  return ((fa: any): MaybeV<A>)
}

Example 2: Signal

class IsSignal {}

export type SignalV<A> = {
  subscribe(subscriber: (a: A) => any): void,
  get(): A,
  set(a: A): void
};

export type Signal<A> = HKT<IsSignal, A>;

export function inj<A>(a: SignalV<A>): Signal<A> {
  return ((a: any): Signal<A>)
}

export function prj<A>(fa: Signal<A>): SignalV<A> {
  return ((fa: any): SignalV<A>)
}

Observations

It seems like flipping between variants like this is a lot of mental complexity where a more direct solution could be possible. Would you care to comment on whether it might be possible to make this less boilerplate-y for creating new types like this?

There's some boilerplate but except the SignalV<A> type alias (which is however pretty handy) all those ingredients seem required.

Finally, is there a reason why map, of, etc can't be static members of the Signal class?

I wanted to implement the static land specification which I prefer over fantasy-land. This open up the possibility to implement different instances (unlike PureScript or Haskell) of a type class if needed. Example: numberAdditionMonoid and numberMultiplicationMonoid

jsonnull commented 8 years ago

I wanted to implement the static land specification which I prefer over fantasy-land. This open up the possibility to implement different instances (unlike PureScript or Haskell) of a type class if needed. Example: numberAdditionMonoid and numberMultiplicationMonoid

Maybe I'm not fully understanding. Even static-land shows implementations for various type classes implemented as static methods of that class.

Example from their README.md:

class MyType = {
  constructor() {
    // ...
  }
  someInstanceMethod() {
    // ...
  }

  static someNonStaticLandStaticMethod() {
    // ...
  }

  // Static Land methods
  static of(x) {
    // ...
  }

  static map(fn, value) {
    // ...
  }
}

export {MyType}

Aside from type annotations, what's missing or impossible in this example that is accomplished in flow-static-land?


A boilerplate idea

There's some boilerplate but except the SignalV type alias (which is however pretty handy) all those ingredients seem required.

So I started to play around with this since it was bugging me.

Looking back at the recipe

  • a nominal type for F (let’s call it IsF) which represent a specific unary type constructor and should not be exported

This is not extremely useful, it's just necessary to distinguish HKT's for Flow. What if we came up with a mechanism that did this while being more expressive and reducing lines of dead code?

  • (optional, it's just handy) a type alias for the concrete value (let’s call it FV<A>)

After playing around a while I see how this can be pretty optional, but I think it's primary usefulness is because the Maybe type in the existing framework cannot also express the properties we want MaybeV to capture. I might be slightly off here, but with what I came up with it would be possible to use or not use as desired. /shrug

  • the main type alias type F<A> = HKT<isF, A>;

This is one of the parts that really got me. What makes isF an isF are the component typeclasses, so what if we got rid of the empty isF class and used the component typeclasses to both create the nominal type for the class, but make the nominal type useful in typechecking. Read on to see what I mean.

  • a way to put a value of type FV<A> into F<A> (let’s call it inj)
  • a way to get back the value FV<A> from F<A> (let’s call it prj)

I'm just not sure if this is necessary. Since FV<a> is optional, I found that when only using the F<A> these were not necessary outside of the identity function or of implementation. I think it's even simpler this way, because it's how of is supposed to work... it creates a minimal default context to wrap the value.

With that said, here's what I came up with

Applicative.js

// @flow
import { HKT } from 'flow-static-land/HKT'

export interface Applicative<F> {
  of<A>(a: A): HKT<F, A>
}

export type Of<F, A> = (a: A) => HKT<F, A>

Functor.js

// @flow
import { HKT } from 'flow-static-land/HKT'

export interface Functor<F> {
  map<A, B>(f: (a: A) => B, fa: HKT<F, A>): HKT<F, A>
}

export type Map<F, A, B> = (f: (a: A) => B, fa: HKT<F, A>) => HKT<F, A>

In these the interfaces Functor and Applicative describe the shape of the typeclasses, so they can be used to check against classes and static structures to ensure the required methods are present. The types for Of and Map you can see used below...

  • [X] Compatibility with ES6 classes?
  • [X] Useful with entirely static methods?
  • [X] Definitions of function types AND interfaces only valid for HKTs?

Maybe.js

import { HKT } from 'flow-static-land/HKT'
import type { Applicative, Of } from './Applicative'
import type { Functor, Map } from './Functor'

// Nominal class informs on the typeclasses present, very expressive
export type MaybeClass = Functor<MaybeClass> & Applicative<MaybeClass>
export type Maybe<A> = HKT<MaybeClass, A>

// Typeclass methods
export const of: Of<MaybeClass, any> = (a) => {
  return ((a: any): Maybe<any>)
}

export const map: Map<MaybeClass, any, any> = (f, fa) => {
  // inj/prj not needed since there's only one Maybe type, but could still be used
  const a = ((fa: any): ?any)
  return a == null ? Nothing : of(f(a))
}

// Maybe methods
export const isNothing: (x: Maybe<*>) => boolean = (x) => {
  return x === Nothing
}

export const Nothing: Maybe<any> = of(null)

// Finally, it's simple to force flow to check the typeclasses using the definition for MaybeClass
if (false) { // eslint-disable-line {
  ({
    map,
    of
  }: MaybeClass)
}

Now what I really like about this is

  • You're able to cut the empty nominal class in favor of a type that is expressive and can be used later.
  • There are types available for each method, so defining of for Maybe is as simple as pulling in the of type from Applicative (name Of to avoid name collisions) and filling in the nominal class and data types it is valid for.
  • Typeclass checking still works and the error messages are great. If I take of out of the bottom struct I get a message saying that property of of Applicative was not found in the struct, and it's pointing to the line where the type MaybeClass is declared, so I can go straight to the implementation.

Can you let me know what you think? Is this simpler and more concise while staying true to current semantics?

gcanti commented 8 years ago

@jsonnull thanks for the detailed explanation.

Even static-land shows implementations for various type classes implemented as static methods of that class

Yes but I don't see why you may want to do that (for example what's the purpose of the class constructor?), a POJO seems to work just fine.

what's missing or impossible in this example that is accomplished in flow-static-land

Well, nothing really missing or impossible I guess, but I want to keep separated the specific unary type constructor (e.g. IsMaybe) from the implementation of the type class instances

class IsMaybe {
  static concat(x, y) { 
    // what if I want to define an additional concat implementation? 
    // Where can I put the definition?
    ...
  }
}

Again, I don't see the value of using a class over a POJO.

I'm just not sure if this is necessary (<= talking about inj and prj)

How can you inject a value without inj? How can you get back a value without prj? Think of Arr<A>:

import * as arr from 'flow-static-land/Arr'

const x: Arr<number> = arr.map(n => n * 2, arr.inj([1, 2, 3])) // <= of is not useful here
const y: Array<number> = arr.prj(x) // <= how can I do this without prj?

The current implementation provides a high degree of type safety, after a few tests with your implementation I found some issues

of is unsafe

const x: Maybe<string> = of(1) // <= no errors

map is unsafe

map((s: string) => s + 'hello', of(1)) // <= no errors

hence the types Of and Map seem not useful. Also, in the map implementation of Either

export function map<L, A, B>(f: (a: A) => B, fa: Either<L, A>): Either<L, B> {
  ...
}

you need an additional L type parameter, not sure if this matches with your type Map.

The MaybeClass type is not nominal (using type Something = ... you can't produce a nominal type):

function foo(x: Maybe<string>): void {}

export type ArrClass = Functor<ArrClass> & Applicative<ArrClass>;
export type Arr<A> = HKT<ArrClass, A>;

function ofArr<A>(a: A): Arr<A> {
  return ((a: any): Arr<A>)
}

foo(ofArr('a')) // <= an Arr instead of a Maybe but no errors

If you are looking for an alternative implementation with less boilerplate (which would be great ;) my advice is to test it against (at least) the following types:

which provide a good spectrum of use cases.