Skip to main content
Version: 2.0.x

Introduction

In addition to abstractions for concrete types, ZIO Prelude provides a set of functional abstractions to describe the common structure of parameterized types.

A parameterized type is a type that is parameterized on one or more other types. For example, a List[A] is parameterized on the element type A.

When we are describing the common structure of parameterized types we are talking about the structure of the parameterized type without knowing about the type it is parameterized on. For example, consider the following two abstractions to describe associative ways of combining two values of a data type.

trait Associative[A] {
def combine(left: => A, right: => A): A
}

trait AssociativeBoth[F[_]] {
def both[A, B](left: => F[A], right: => F[B]): F[(A, B)]
}

The Associative abstraction describes a way of associatively combining two concrete data types.

The syntax is relatively straightforward here. The Associative is parameterized on a type A that has an associative combine operator.

When we define an instance of Associative we know the type of the values we are combining and can use that information in implementing the combine operator. For example we know we have two Int values and can combine them by adding them together.

implicit val IntAssociative: Associative[Int] =
new Associative[Int] {
def combine(left: => Int, right: => Int): Int =
left + right
}
// IntAssociative: Associative[Int] = repl.MdocSession$MdocApp$$anon$1@60a35d0d

In contrast, the AssociativeBoth abstraction describes a way of associatively combining two parameterized types.

The syntax here may look a little new. Instead of being parameterized on an A, AssociativeBoth is parameterized on an F[_].

There is nothing special about the F here, just like the A in Associative it is just a placeholder for some type. By convention we start with A for concrete types and F for parameterized types but we can call these types whatever we want.

What is important is the [_] after F. This tells the Scala compiler that this is a parameterized type, such as a List rather than a List[Int].

The importance of this becomes clear when we define an instance of AssociativeBoth because we do not know anything about the types A and B that the left and right values are parameterized on. This prevents us from doing anything with the A and B values that requires knowledge of their types and so essentially forces us to work exclusively at the level of the F structure.

To see this, consider how we would implement an instance of AssociativeBoth for List.

implicit val ListAssociativeBoth: AssociativeBoth[List] =
new AssociativeBoth[List] {
def both[A, B](left: => List[A], right: => List[B]): List[(A, B)] =
left.flatMap(a => right.map(b => (a, b)))
}
// ListAssociativeBoth: AssociativeBoth[List] = repl.MdocSession$MdocApp$$anon$2@73f08212

Notice how the AssociativeBoth instance is parameterized on List rather than a list of any specific type. This is important because it says the ListAssociativeBoth instance knows how to combine any two lists in an associative way, not just two lists of some specific type.

What would the implementation of such a way of combining look like? It would not be able to use any information about the A or B values, because they could be anything, so it would have to combine them in a way that worked for any A and B values.

The AssociativeBoth abstraction does this by combining the A and B values into a tuple. We will see later that this is one of two fundamental ways that we can combine two values in a generic way.

In our implementation for List we return the Cartesian product of the two lists, which is an associative operation after reassociating nested tuples.

We'll talk more about AssociativeBoth in the section on that abstraction. For now the goal of this discussion is not to discuss AssociativeBoth in detail but to get a sense of what it means to describe the structure of a parameterized type instead of a concrete one.

Just like for concrete types, the abstractions for parameterized types fall into two categories.

Properties Of Parameterized Types

The first set of abstractions define properties of single values of a parameterized type F[A]. All of them describe the fundamental nature of the A parameter with respect to F.

Invariant

The first relationship that A can have to F is that F both produces and consumes A values. For example, a JsonCodec[A] both produces A values by turning JSON into A values and consumes A values by turning A values into JSON.

Parameterized types that both produce and consume A values are invariant in the A type parameter and we describe them with the Invariant abstraction.

An invariant type F[A] can be transformed into an F[B] with an Equivalence[A, B] using the invmap operator. Conceptually, if we have a data type that produces and consumes A values we can always turn it into a data type that produces and consumes B values by transforming all inputs into A values and all outputs back to B values with the equivalence relationship.

This is useful to "lift" equivalence relationships into the context of a parameterized type. For example, if we have defined an equivalence between new and old versions of our data model we can use Invariant to convert the JsonCodec for the old data model into a JsonCodec for the new data model.

Covariant

The second relationship that A can have to F is that F produces A values but does not consume them. Data types that produce A values may either just contain existing A values, like a Chunk, or potentially produce A values at some point in the future, like a ZIO.

Parameterized types that produce but do not consume A values are covariant in the A type parameter and we describe them with the Covariant abstraction.

A covariant type F[A] can be transformed into an F[B] with a function A => B using the map operator. Conceptually, if we have a data type that produces A values then we can create a data type that produces B values simply by taking each output and transforming it with the function.

This is useful to allow us to transform the output of a covariant type to build richer data pipelines. For example, if we had a function that decrypted some data and a ZIO effect that loaded the encrypted data from a file we could return a new ZIO effect that produced the decrypted data, which we could then compose with the rest of our program.

Contravariant

The third relationship that A can have to F is that F consumes A values but does not produce them. Examples of data types that consume values of one or more types include Scala functions with respect to their input, ZIO with respect to its environment type, and ZSink with respect to stream elements.

Parameterized types that consume but do not produce A values are contravariant in the A type parameter and we describe them with the Contravariant abstraction.

A contravariant type F[A] can be transformed into an F[B] with a function B => A using the contramap operator. Conceptually, if we have a data type that consumes A values then we can create a data type that consumes B values simply by transforming each B value into an A value with the function before feeding it to the original data type.

This is useful to allow us to transform the input of a contravariant type to build richer data pipelines. For example, if we have a sink that takes chunks of bytes and writes them to a file we could transform it into a sink that takes strings and writes them to a file by providing a function to transform strings into bytes.

ForEach

The ForEach abstraction builds on the Covariant abstraction by describing a data type that not only produces A values but actually contains zero or more A values as opposed to merely being able to potentially generate them at some point in the future. For example, a Chunk has zero or more existing A values and so has a ForEach instance defined for it whereas a ZIO merely may produce an A value at some point in the future.

A data type with a ForEach instance contains zero or more existing A values so we can iterate over it, potentially transforming its values while maintaining its structure using an operator like ZIO.foreach. We can also tear it down entirely and reduce it to a summary value with an operator like foldLeft.

This ability to get an A value out of the data type only exists because ForEach describes a data type where the A values already exist. If we had a data type like ZIO then we would not be able to get an A value out of it because a ZIO is only a description of a workflow that may produce an A value when it is run.

The ForEach abstraction is one of the most practically useful functional abstractions in ZIO Prelude. Most collection operators can be defined in terms of ForEach and especially in combination with the abstractions for describing ways of combining concrete types it provides very powerful ways of working with collections.

NonEmptyForEach

The NonEmptyForEach abstraction further builds on ForEach to describe a parameterized type that contains one or more existing A values. For example, a NonEmptyChunk is guaranteed to contain at least one A value.

Because a data type with a NonEmptyForEach instance must always contain at least one existing A value we can define certain operators for it that would not be safe to define for a data type that might not contain any A values. For example, we can define a reduce operator that reduces the collection to a summary value with an associative operator whereas with a ForEach instance we can only define a fold operator that also requires an identity value to handle the empty case.

Combining Parameterized Types

The second set of abstractions describe ways of combining parameterized types.

Because of the additional structure of parameterized types there are three separate ways that we can combine values of parameterized types, each of which may satisfy properties of associativity, commutativity, and identity.

The first way of combining an F[A] and an F[B] is to return both A and B values. This corresponds to some notion of doing both things, though what exactly this means will depend on the parameterized type and the combining operation.

This way of combining is described by the AssociativeBoth, CommutativeBoth, and IdentityBoth functional abstractions.

The second way of combining an F[A] and an F[B] is to return either an A or a B value. This corresponds to some notion of choosing which value to return, though again what exactly this means and how we choose will depend on the parameterized type and the combining operation.

This way of combining is described by the AssociativeEither, CommutativeEither, and IdentityEither functional abstractions.

The third way of combining actually combines two layers of F values, converting an F[F[A]] into an F[A]. This corresponds to some notion of evaluating the outer layer and then evaluating the inner layer.

This way of combining is described by the AssociativeFlatten and IdentityFlatten functional abstractions.

AssociativeBoth

The AssociativeBoth abstraction describes a parameterized type F[A] with an associative operator both that can combine a value of type F[A] and a value of type F[B] into a value of type F[(A, B)]. This is described by the zip operator on ZIO and corresponds to running the left value and then running the right value.

CommutativeBoth

CommutativeBoth describes a way of combining an F[A] and an F[B] to produce an F[(A, B)] that is both associative and commutative.

This is described by the zipPar operator on ZIO and corresponds to running the left value and the right value in parallel, since this is the only way that the order will not matter as required by the commutative property.

IdentityBoth

The IdentityBoth abstraction describes a way of combining an F[A] and an F[B] to produce an F[(A, B)] that is both associative and has an identity value of type F[Any]. In ZIO the identity value is value is ZIO.unit since zipping any value with unit is equivalent to returning the original value unchanged

AssociativeEither

The AssociativeEither abstraction describes a parameterized type F[A] with an associative operator either that can combine a value of type F[A] and a value of type F[B] into a value of type F[Either[A, B]]. This is described by the orElseEither operator on ZIO and corresponds to running the left value, but if it fails then running the right value.

CommutativeEither

CommutativeEither builds on AssociativeEither by describing an operator that combines an F[A] and and F[B] to produce an F[Either[A, B]] in a way that is both associative and commutative. In ZIO this corresponds to the raceEither operator which runs both effects concurrently and returns the first to succeed, since running both effects concurrently is the only way that the order will not matter as required by the commutative property.

IdentityEither

The IdentityEither abstraction describes a way of combining an F[A] and an F[B] to produce an F[Either[A, B]] that is both associative and has an identity value of type F[Nothing]. ZIO does not have such an identity value since it is not possible to fail without any error but conceptually such an identity would correspond to a failure that did not contain any information.

AssociativeFlatten

The AssociativeFlatten abstraction describes the ability to flatten two layers of F structure in an associative way. In ZIO this corresponds to the flatten operator, and in combination with the Covariant abstraction the flatMap operator, corresponding to running one ZIO value and using its result to generate another ZIO value and running that value.

IdentityFlatten

The IdentityFlatten abstraction describes an identity value of type F[Any] with respect to the associative flattening operation defined by AssociativeFlatten. In ZIO this corresponds to ZIO.unit and is the same as the identity value defined by the IdentityBoth instance.