In the first article of the series we described the principles behind the unique encoding of HKTs used in @effect-ts/core
, it's now time to take a look at the details.
We will start by exploring the Type-Classes
available and we will progressively make some examples of usage.
At the end we will discuss the module structure and what's available a-la-carte.
Project Setup
Let's start with a simple new project (be bothered only if you come from Scala/Haskell, ignore if you know TS):
mkdir effect-ts-series;
cd effect-ts-series;
npm init -y;
yarn add typescript@next @effect-ts/core @types/node;
mkdir src;
Let's create a file tsconfig.json
as follow:
{
"compilerOptions": {
"strict": true,
"target": "ES5",
"outDir": "lib",
"lib": ["ESNext"]
},
"include": ["src/**/*.ts"]
}
Let's create a file src/index.ts
with the following content:
import * as T from "@effect-ts/core/Effect";
import { pipe } from "@effect-ts/core/Function";
pipe(
T.effectTotal(() => {
console.log("Hello world");
}),
T.runMain
);
and add a build script to your package.json
as follows:
{
"name": "effect-ts-series",
"version": "1.0.0",
"description": "Effect-TS Series",
"main": "lib/index.js",
"scripts": {
"build": "tsc",
"start": "node lib/index.js"
},
"keywords": [],
"author": "Michael Arnaldi",
"license": "MIT",
"dependencies": {
"@effect-ts/core": "^0.2.0",
"@types/node": "^14.11.2",
"typescript": "^4.1.0-dev.20201004"
}
}
We should be able to compile the project with:
yarn build
And run it:
$ yarn start
yarn run v1.22.4
$ node lib/index.js
Hello world
Done in 0.46s.
Introduction to ZIO-Prelude's Type-Classes
First of all let's start with a little bit of theory and reasons why to revise the classical type-classes hierarchy.
Statically Typed Functional Programming as we know it today effectively has roots in haskell and in its design principles; for years we have, as a community, gone through an exercise of borrowing principles one by one and finding its way into different languages.
The process of porting features from one language to another is not an easy one and it requires multiple steps, the first of which is finding similar encodings and secondly improving upon the basics.
Haskell’s type-system is inspired by category theory, but mathematically speaking it's only an “approximation” that focuses on a specific subset of the theory that makes sense in languages of the HM family. We should not be blind to the rest of the theory especially when extending the concepts to different languages because the same assumptions made in haskell might not hold in ours (like for example all of the functions being curried).
ZIO Prelude can be considered the second step of abstraction and adaptation of functional programming concepts to Scala, it is designed for Scala and leverages all the features available in the language.
Lucky for us the features of Scala as a language are very similar to the features of TypeScript at the type-system level and in some cases the TypeScript type-system is even more flexible (i.e. supporting intersection & union types).
Furthermore ZIO Prelude takes a look at a broader range of constructs from mathematics that have previously been perceived as secondary.
Let's take a look at Functor
from fp-ts
, we will list only one definition to keep things small:
export interface Functor<F> {
readonly URI: F
readonly map: <A, B>(fa: HKT<F, A>, f: (a: A) => B) => HKT<F, B>
}
Similarly defined in other fp-languages like purescript
& haskell
this typeclass shows a bias, in fact in category theory a Functor can be Covariant or Contravariant while here we associate the Functor
name with a specific case
Let's now take a look at how a Functor
is defined categorically:
A Functor
between Categories
is a mapping of both objects
and morphisms
that preserves the categorical structure, there are at least 2 types of Functors
, one that preserves the direction of the morphisms
and one that inverts the direction.
Those are called Covariant Functor
& Contravariant Functor
.
From the above definition from fp-ts
we realise the haskell
bias, everything is pointed towards Covariant
Functors
.
ZIO Prelude use different naming and leverages an extremely orthogonal design (i.e. minimal type-classes, easily composable), conceptually the same but more close to the actual laws the typeclass respect.
Covariant
Let's take a look at the equivalent of Functor
in @effect-ts/core
:
export interface Covariant<F extends HKT.URIS, C = HKT.Auto> extends HKT.Base<F, C> {
readonly map: <A, B>(
f: (a: A) => B
) => <N extends string, K, Q, W, X, I, S, R, E>(
fa: HKT.Kind<F, C, N, K, Q, W, X, I, S, R, E, A>
) => HKT.Kind<F, C, N, K, Q, W, X, I, S, R, E, B>
}
Code at: core/src/Prelude/Covariant/index.ts
The name used is Covariant
as in Covariant Functor
.
Let's take a look at some instances for known data-types:
export const Covariant = P.instance<P.Covariant<[EitherURI], V>>({
map: E.map
})
Where E
is the Either
module, V = Prelude.V<"E", "+">
to indicate the covariance of the parameter E
(in Either
the error channel E
mixes with union type as we will see later).
Monad
Let's take a look at the dear loved Monad
:
export type Monad<F extends URIS, C = Auto> = IdentityFlatten<F, C> & Covariant<F, C>
export type IdentityFlatten<F extends URIS, C = Auto> = AssociativeFlatten<F, C> &
Any<F, C>
export interface Any<F extends HKT.URIS, C = HKT.Auto> extends HKT.Base<F, C> {
readonly any: <
N extends string = HKT.Initial<C, "N">,
K = HKT.Initial<C, "K">,
Q = HKT.Initial<C, "Q">,
W = HKT.Initial<C, "W">,
X = HKT.Initial<C, "X">,
I = HKT.Initial<C, "I">,
S = HKT.Initial<C, "S">,
R = HKT.Initial<C, "R">,
E = HKT.Initial<C, "E">
>() => HKT.Kind<F, C, N, K, Q, W, X, I, S, R, E, any>
}
export interface AssociativeFlatten<F extends HKT.URIS, C = HKT.Auto>
extends HKT.Base<F, C> {
readonly flatten: <
N extends string,
K,
Q,
W,
X,
I,
S,
R,
E,
A,
N2 extends string,
K2,
Q2,
W2,
X2,
I2,
S2,
R2,
E2
>(
ffa: HKT.Kind<
F,
C,
N2,
K2,
Q2,
W2,
X2,
I2,
S2,
R2,
E2,
HKT.Kind<
F,
C,
HKT.Intro<C, "N", N2, N>,
HKT.Intro<C, "K", K2, K>,
HKT.Intro<C, "Q", Q2, Q>,
HKT.Intro<C, "W", W2, W>,
HKT.Intro<C, "X", X2, X>,
HKT.Intro<C, "I", I2, I>,
HKT.Intro<C, "S", S2, S>,
HKT.Intro<C, "R", R2, R>,
HKT.Intro<C, "E", E2, E>,
A
>
>
) => HKT.Kind<
F,
C,
HKT.Mix<C, "N", [N2, N]>,
HKT.Mix<C, "K", [K2, K]>,
HKT.Mix<C, "Q", [Q2, Q]>,
HKT.Mix<C, "W", [W2, W]>,
HKT.Mix<C, "X", [X2, X]>,
HKT.Mix<C, "I", [I2, I]>,
HKT.Mix<C, "S", [S2, S]>,
HKT.Mix<C, "R", [R2, R]>,
HKT.Mix<C, "E", [E2, E]>,
A
>
}
Code at: core/src/Prelude/Monad/index.ts
Apart from being slightly verbose, @effect-ts/core
supports up to 10 different type parameters that can mix dynamically based on the variance annotation specified at the instance level.
We can see how well Monad
is separated orthogonally across different, more specific, lawful type-classes.
We read Monad
is a Covariant
functor with an identity
and an Associative
flatten operation.
Pretty much describes itself the laws a Monad
has to respect.
Let's take a look at a few instances of Monad
for various data-types
and let's have a look at how variance works.
We will first introduce a generic operation to showcase how to write code that works with any kind, we will take a look at the generic chain
function that given an instance of Monad
performs a series of operations where the second operation depends on the result of the first.
export function chainF<F extends HKT.URIS, C = HKT.Auto>(
F: Monad<F, C>
): <N2 extends string, K2, Q2, W2, X2, I2, S2, R2, E2, A, B>(
f: (a: A) => HKT.Kind<F, C, N2, K2, Q2, W2, X2, I2, S2, R2, E2, B>
) => <N extends string, K, Q, W, X, I, S, R, E>(
fa: HKT.Kind<
F,
C,
HKT.Intro<C, "N", N2, N>,
HKT.Intro<C, "K", K2, K>,
HKT.Intro<C, "Q", Q2, Q>,
HKT.Intro<C, "W", W2, W>,
HKT.Intro<C, "X", X2, X>,
HKT.Intro<C, "I", I2, I>,
HKT.Intro<C, "S", S2, S>,
HKT.Intro<C, "R", R2, R>,
HKT.Intro<C, "E", E2, E>,
A
>
) => HKT.Kind<
F,
C,
HKT.Mix<C, "N", [N2, N]>,
HKT.Mix<C, "K", [K2, K]>,
HKT.Mix<C, "Q", [Q2, Q]>,
HKT.Mix<C, "W", [W2, W]>,
HKT.Mix<C, "X", [X2, X]>,
HKT.Mix<C, "I", [I2, I]>,
HKT.Mix<C, "S", [S2, S]>,
HKT.Mix<C, "R", [R2, R]>,
HKT.Mix<C, "E", [E2, E]>,
B
>
export function chainF<F>(F: Monad<HKT.UHKT<F>>) {
return <A, B>(f: (a: A) => HKT.HKT<F, B>) => flow(F.map(f), F.flatten)
}
Code at: core/src/Prelude/DSL/dsl.ts
Let's use this generic chainF
function on a few different instances:
import * as IO from "@effect-ts/core/XPure/XIO";
import * as Either from "@effect-ts/core/Classic/Either";
import * as Effect from "@effect-ts/core/Effect";
import { pipe } from "@effect-ts/core/Function";
import { chainF } from "@effect-ts/core/Prelude/DSL";
const chainIO = chainF(IO.Monad);
const chainEither = chainF(Either.Monad);
const chainEffect = chainF(Effect.Monad);
// IO.XIO<number>
const io = pipe(
IO.succeed(0),
chainIO((n) => IO.succeed(n + 1))
);
const checkPositive = (n: number): Either.Either<string, number> =>
n > 0 ? Either.right(n) : Either.left("error");
// Either.Either<string, number>
const either = (n: number) =>
pipe(
n,
checkPositive,
chainEither((n) => Either.right(n + 1))
);
// Effect.Effect<{ s: string; } & { n: number; }, string | number, number>
const effect = pipe(
Effect.accessM((_: { n: number }) =>
Effect.ifM(Effect.succeed(_.n > 0))(() => Effect.succeed(_.n))(() =>
Effect.fail("error")
)
),
chainEffect((n) =>
Effect.accessM((_: { s: string }) =>
Effect.ifM(Effect.succeed(_.s.length > 1))(() =>
Effect.succeed(n + _.s.length)
)(() => Effect.fail(0))
)
)
);
As we can see parameters R
, E
are mixed differently depending on the variance of the instance specified as:
// for Effect
export type V = P.V<"R", "-"> & P.V<"E", "+">
// for Either
export type V = P.V<"E", "+">
Applicative
Let's take a look at the good old friend Applicative
, the first thing to note is that Applicative
is completely independent from Monad
not really like in Haskell
land!
export type Applicative<F extends URIS, C = Auto> = IdentityBoth<F, C> & Covariant<F, C>
export type IdentityBoth<F extends URIS, C = Auto> = AssociativeBoth<F, C> & Any<F, C>
export interface AssociativeBoth<F extends HKT.URIS, C = HKT.Auto>
extends HKT.Base<F, C> {
readonly both: <N2 extends string, K2, Q2, W2, X2, I2, S2, R2, E2, B>(
fb: HKT.Kind<F, C, N2, K2, Q2, W2, X2, I2, S2, R2, E2, B>
) => <N extends string, K, Q, W, X, I, S, R, E, A>(
fa: HKT.Kind<
F,
C,
HKT.Intro<C, "N", N2, N>,
HKT.Intro<C, "K", K2, K>,
HKT.Intro<C, "Q", Q2, Q>,
HKT.Intro<C, "W", W2, W>,
HKT.Intro<C, "X", X2, X>,
HKT.Intro<C, "I", I2, I>,
HKT.Intro<C, "S", S2, S>,
HKT.Intro<C, "R", R2, R>,
HKT.Intro<C, "E", E2, E>,
A
>
) => HKT.Kind<
F,
C,
HKT.Mix<C, "N", [N2, N]>,
HKT.Mix<C, "K", [K2, K]>,
HKT.Mix<C, "Q", [Q2, Q]>,
HKT.Mix<C, "W", [W2, W]>,
HKT.Mix<C, "X", [X2, X]>,
HKT.Mix<C, "I", [I2, I]>,
HKT.Mix<C, "S", [S2, S]>,
HKT.Mix<C, "R", [R2, R]>,
HKT.Mix<C, "E", [E2, E]>,
readonly [A, B]
>
}
Code at: core/src/Prelude/Applicative/index.ts
Nothing easier, as we read an Applicative
is a Covariant
functor with an identity and an Associative
operation Both
.
It is theoretically the same as the classic variant with ap
but much more clear from the laws point of view and from the usability standpoint.
Also if we go by the theory, we can read from ncatlab.org:
In computer science, applicative functors (also known as idioms) are the programming equivalent of lax monoidal functors with a tensorial strength in category theory.
If you know the terms involved you will recognise that this definition at the end is much closer to the theory compared to the classic ap
.
Let's take a look at some DSL available for Applicative
functors:
import * as Either from "@effect-ts/core/Classic/Either";
import * as DSL from "@effect-ts/core/Prelude/DSL";
const struct = DSL.structF(Either.Applicative);
const tupled = DSL.tupledF(Either.Applicative);
// Either.Either<never, { a: number; b: number; c: number; }>
const resultStruct = struct({
a: Either.right(0),
b: Either.right(1),
c: Either.right(2),
});
// Either.Either<never, [number, number, number]>
const resultTupled = tupled(Either.right(0), Either.right(1), Either.right(2));
We leave it as an exercise for the reader to derive the Monad
& Applicative
declarations of fp-ts
from this one and vice versa (hint: you can use functions available in Prelude/DSL
).
Traversable
Let's take a look at the dear old friend Traversable
:
export interface Foreach<F extends HKT.URIS, C = HKT.Auto> {
<G extends HKT.URIS, GC = HKT.Auto>(G: IdentityBoth<G, GC> & Covariant<G, GC>): <
GN extends string,
GK,
GQ,
GW,
GX,
GI,
GS,
GR,
GE,
A,
B
>(
f: (a: A) => HKT.Kind<G, GC, GN, GK, GQ, GW, GX, GI, GS, GR, GE, B>
) => <FN extends string, FK, FQ, FW, FX, FI, FS, FR, FE>(
fa: HKT.Kind<F, C, FN, FK, FQ, FW, FX, FI, FS, FR, FE, A>
) => HKT.Kind<
G,
GC,
GN,
GK,
GQ,
GW,
GX,
GI,
GS,
GR,
GE,
HKT.Kind<F, C, FN, FK, FQ, FW, FX, FI, FS, FR, FE, B>
>
}
export interface Traversable<F extends HKT.URIS, C = HKT.Auto>
extends HKT.Base<F, C>,
Covariant<F, C> {
readonly foreachF: Foreach<F, C>
}
Code at: core/src/Prelude/Traversable/index.ts
Nothing exceptionally different from the classic version apart from the name of the foreachF
function (originally called traverse
).
Let's take a look at its usage:
import * as Either from "@effect-ts/core/Classic/Either";
import * as Array from "@effect-ts/core/Classic/Array";
import * as Record from "@effect-ts/core/Classic/Record";
import { pipe } from "@effect-ts/core/Function";
import { sequenceF } from "@effect-ts/core/Prelude";
const foreachArray = Array.Traversable.foreachF(Either.Applicative);
// Either.Either<string, Array.Array<number>>
const resultArray = pipe(
[0, 1, 2, 3],
foreachArray((n) => (n > 2 ? Either.left("error") : Either.right(n)))
);
const foreachRecord = Record.Traversable.foreachF(Either.Applicative);
// Either.Either<string, Readonly<Record<"a" | "b" | "c" | "d", number>>>
const resultRecord = pipe(
{
a: 0,
b: 0,
c: 0,
d: 0,
},
foreachRecord((n) => (n > 2 ? Either.left("error") : Either.right(n)))
);
const sequenceArray = sequenceF(Array.Traversable)(Either.Applicative);
// Either.Either<string, Array.Array<number>>
const sequenceArrayResult = sequenceArray([
Either.left("error"),
Either.right(0),
Either.right(1),
Either.right(2),
]);
Identity
The dear old Monoid
:
export interface Identity<A> extends Associative<A> {
readonly identity: A
}
export interface Associative<A> extends Closure<A> {
readonly Associative: "Associative"
}
export interface Closure<A> {
combine(r: A): (l: A) => A
}
Like before without previously knowing the laws we can read that a Monoid
has a combine
associative operation with an identity
element.
Foldable
Nothing special about Foldable
:
export type Foldable<F extends URIS, C = Auto> = ReduceRight<F, C> &
Reduce<F, C> &
FoldMap<F, C>
export interface Reduce<F extends HKT.URIS, C = HKT.Auto> extends HKT.Base<F, C> {
readonly reduce: <A, B>(
b: B,
f: (b: B, a: A) => B
) => <N extends string, K, Q, W, X, I, S, R, E>(
fa: HKT.Kind<F, C, N, K, Q, W, X, I, S, R, E, A>
) => B
}
export interface ReduceRight<F extends HKT.URIS, C = HKT.Auto> extends HKT.Base<F, C> {
readonly reduceRight: <A, B>(
b: B,
f: (a: A, b: B) => B
) => <N extends string, K, Q, W, X, I, S, R, E>(
fa: HKT.Kind<F, C, N, K, Q, W, X, I, S, R, E, A>
) => B
}
export interface FoldMap<F extends HKT.URIS, C = HKT.Auto> extends HKT.Base<F, C> {
readonly foldMap: FoldMapFn<F, C>
}
export interface FoldMapFn<F extends HKT.URIS, C = HKT.Auto> {
<M>(I: Identity<M>): <A>(
f: (a: A) => M
) => <N extends string, K, Q, W, X, I, S, R, E>(
fa: HKT.Kind<F, C, N, K, Q, W, X, I, S, R, E, A>
) => M
}
Let's take a look at using some Foldable
instances.
import as Array from "@effect-ts/core/Classic/Array";
import as Record from "@effect-ts/core/Classic/Record";
import * as Identity from "@effect-ts/core/Classic/Identity";
const fromArray = Record.fromFoldable(Identity.string, Array.Foldable);
// Readonly<Record<string, string>>
const record = fromArray([
["a", "foo"],
["b", "bar"],
]);
Module Structure
The @effect-ts/core
package is organized in directories as follow:
-
@effect-ts/core/Classic
: lightweight modules and commonly used type-classes, to be used everywhere (browser, node) -
@effect-ts/core/Effect
: effect based modules, primarily targeting node development this set of modules is a full suite to structure highly concurrent & well testable services with a variety of data types including:Fiber, FiberRef, Layer, Managed, Promise, Queue, Ref, RefM, Schedule, Scope, Semaphore, Stream, Supervisor
. It can be used in frontend development too but there is a cost-benefit to be considered, if the project is large enough it might be beneficial because of project based amortisation in smaller projects and specific use data types fromClassic
likeAsync
are preferrable. -
@effect-ts/core/Function
: function based utilities likepipe
-
@effect-ts/core/Newtype
: newtype definition and common newtypes -
@effect-ts/core/Utils
: small set of utilities for pattern matching and intersection -
@effect-ts/core/XPure
: data-types based onXPure
, an efficient synchronous data-type that support Contravariant State Input, Covariant State Output, Contravariant Reader, Covariant Error, Output. The purpose ofXPure
is to serve as a basis to construct multiple data-types that can satisfy specific capabilities. It is also very lightweight and can be especially efficient if used across different data-types.XPure
is also used to back theClassic/Sync
data-type that is natively included as a primitive ofEffect
. -
@effect-ts/core/Modules
: internal usage