Closed jsonnull closed 7 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
F
(let’s call it IsF
) which represent a specific unary type constructor and should not be exportedFV<A>
)type F<A> = HKT<isF, A>;
FV<A>
into F<A>
(let’s call it inj
)FV<A>
from F<A>
(let’s call it prj
)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
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 itIsF
) 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>
intoF<A>
(let’s call itinj
)- a way to get back the value
FV<A>
fromF<A>
(let’s call itprj
)
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...
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
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.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?
@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
andprj
)
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:
Maybe<A>
Arr<A>
Either<L, R>
Eff<E, A>
or Reader<E, A>
which provide a good spectrum of use cases.
Hello, I'm trying to figure out what's going on here.
I'll attempt to break down what's going on here based on my current understanding.
Signal
is the declared type for the Signal "class" we're creating, and will be an instance ofApplicative
,Functor
, etc. (as seen later in the file)SignalV
(forSignal variant
?) is the type signature for the object with functionssubscribe
,get
, etc. which has the unique behavior of this objectinj
(inject
?) is an identity function that takes an object of typeSignalV
and casts it to aSignal
for typechecking purposesprj
(project
?) is an identity function that takes an object of typeSignal
and casts it to aSignalV
for typechecking purposesI'm assuming the separate
Signal
andSignalV
types as well as the need forinj
andprj
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 toinj
the typeSignal
is present on the value returned, and vice versa forprj
. 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 theSignal
class? It seems like there are really multiple things in play here: aSignal
type which is used to characterize what these instances ofmap
,of
, and so on are valid for, whileSignalV
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.