loading...
Cover image for Effect-TS Core: ZIO-Prelude Inspired Typeclasses & Module Structure
MATECHS

Effect-TS Core: ZIO-Prelude Inspired Typeclasses & Module Structure

mikearnaldi profile image Michael Arnaldi ・12 min read

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:

Cova-Contra (1)

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 from Classic like Async are preferrable.
  • @effect-ts/core/Function : function based utilities like pipe
  • @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 on XPure, an efficient synchronous data-type that support Contravariant State Input, Covariant State Output, Contravariant Reader, Covariant Error, Output. The purpose of XPure 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 the Classic/Sync data-type that is natively included as a primitive of Effect.
  • @effect-ts/core/Modules: internal usage

Discussion

pic
Editor guide