Functional Programming with Kotlin and Arrow – More on Typeclasses
Continuing the Functional Programming with Kotlin and Arrow Part 2: Categories and Functors tutorial, you’ll now go even further, using a specific and common use case, with a better understanding of data types and typeclasses, from Functor to Monad, passing through Applicatives and Semigroups.
Version
- Kotlin 1.3, Other, IntelliJ IDEA

In the previous tutorial Functional Programming with Kotlin and Arrow Part 2: Categories and Functors, you had the opportunity to learn about functors, their meaning in the context of Category theory and how to use them in your code. You also implemented the Maybe functor using both plain Kotlin and the Arrow framework.
In this tutorial, you’ll use a specific and common-use case to go further and develop a better understanding of data types and typeclasses, from functor to monad. You’ll discover applicatives and semigroups, too.
Working through this tutorial, you’ll:
- Learn what a data type is by using the
Result<E,A>
data type in a practical example. - Create a bifunctor typeclass implementation for
Result<E,A>
. - Discover what applicatives are and how to use one to solve a classical problem of creation and validation of entities.
- Explore how a semigroup can be useful in cases of error management.
- Understand what a monad is and why you need this kind of data type in your program.
Time to do some coding magic! :]
Getting Started
Download the materials for this tutorial using the Download Materials button at the top or bottom of this page. Open the project using IntelliJ 2019.x or greater. You can check out its structure in the following image:
It’s important to note that:
- You’re going to write most of
main()
in the external src folder. This is where FunctionalFetcher.kt is. - The arrow module contains the data types and typeclasses submodules. These depend on the Arrow library. You’ll need two modules because the code generation must be done before the one for typeclasses — which usually depends on the previous one.
Start by opening FunctionalFetcher.kt and locating the following code:
// 1
class FetcherException(override val message: String) :
IOException(message)
// 2
object FunctionalFetcher {
fun fetch(url: URL): String {
try {
// 3
with(url.openConnection() as HttpURLConnection) {
requestMethod = "GET"
// 4
val reader = inputStream.bufferedReader()
return reader.lines().asSequence().fold(StringBuilder()) { builder, line ->
builder.append(line)
}.toString()
}
} catch (ioe: IOException) {
// 5
throw FetcherException(ioe.localizedMessage)
}
}
}
This code:
- Declares
FetcherException
as a custom exception. - Defines
FunctionalFetcher
. It containsfetch()
for, well, fetching some content from the network given a URL parameter. - Opens a
HttpURLConnection
with the HTTPGET
. - Reads and accumulates all the lines into a
String
using aStringBuilder
. - Throws a
FetcherException
that encapsulates the error, if any.
You can run the previous code using main()
:
fun main() {
val ok_url = URL("https://jsonplaceholder.typicode.com/todos")
val error_url = URL("https://error_url.txt")
println(FunctionalFetcher.fetch(ok_url))
}
If you use ok_url
, everything should be fine. The JSON content should be displayed as follows:
[
{
"userId":1,
"id":1,
"title":"delectus aut autem",
"completed":false
},
- - -
]
If you use the error_url
, you’ll get an exception like this:
Exception in thread "main" com.raywenderlich.fp.FetcherException: error_url.txt
True, that’s a rather simple example. But you’re going to improve upon it using some interesting functional programming concepts.
Finding a Functional Fetcher
The fetch()
you defined in the previous example has several problems. First, it’s not a pure function; it has some side effects. That’s because it can throw an exception, which isn’t one of the possible values for its return type Int
.
throw FetcherException(ioe.localizedMessage)
expression is of type Nothing
, a subtype of any type and therefore a subtype of Int
, too. Even so, the side-effect problem remains.In the first tutorial of this series, Functional Programming with Kotlin and Arrow: Getting Started, you learned how to deal with side effects. One potential solution is to move it as part of the return type for the function.
In this case, you could replace the previous code in FunctionalFetcher.kt with the following:
object FunctionalFetcher {
// 1
fun fetch(url: URL): Pair<FetcherException?, String?> {
try {
with(url.openConnection() as HttpURLConnection) {
requestMethod = "GET"
val reader = inputStream.bufferedReader()
// 2
return null to reader.lines().asSequence().fold(StringBuilder()) { builder, line ->
builder.append(line)
}.toString()
}
} catch (ioe: IOException) {
// 3
return FetcherException(ioe.localizedMessage) to null
}
}
}
With this code, you have:
- Changed the signature of
fetch()
to return aPair<FetcherException?, String?>
. This makes it compatible with all the possible results in case of error or success. It’s important to note the nullability for the two different types of thePair
. - In case of success, returned the pair instance with
null
as a value for the first property and the JSON text for the second. - In case of error, set the exception as a value of the first property and
null
as a value for the second.
The function is now pure, but it isn’t type-safe. That’s because of Pair<FetcherException?, String?>
. It can represent values where the first and second property is either present or missing. fetch()
can succeed or fail, but not both.
Introducing the Result<E,T> Data Type
A potential solution to the previous type-safety problem is Result<E,T>
. Remember that a data type allows you to add a specific context to some data. For instance, Maybe<T>
allows you to represent the context of the presence or the absence for a value of type T
. This doesn’t depend on the type T
at all. It’s another dimension.
The same holds for Result<E,T>
. Its context is the chance to contain a value of type E
or a value of type T
but not both. Again, this doesn’t depend on either E
or T
.
Result<E,T>
is very similar to Either<A,B>
. Its context offers the chance to have only a value of type A
or type B
, but not both. This is more generally compared to Result<E,T>
, where E
represents a failure, while T
represents a value that results from a successful operation.Create a Result.kt file into the data-type submodule. Then copy the following code:
// 1
sealed class Result<out E, out A>
// 2
class Success<out A>(val a: A) : Result<Nothing, A>()
// 3
class Error<out E>(val e: E) : Result<E, Nothing>()
This code:
- Defines
Result<E,T>
using a sealed class. - In case of success, creates
Success<T>
as the implementation that encapsulates a result of typeT
. - In case of failure, creates
Error<E>
as the implementation that encapsulates the exception of typeE
.
Nothing
, a subtype of any other Kotlin type.You can now use the Result<E,T>
in a new version of your FunctionalFetcher
. Replace the previous code in FunctionalFetcher.kt with the following:
object FunctionalFetcher {
// 1
fun fetch(url: URL): Result<FetcherException, String> {
try {
with(url.openConnection() as HttpURLConnection) {
requestMethod = "GET"
val reader = inputStream.bufferedReader()
val json = reader.lines().asSequence().fold(StringBuilder()) { builder, line ->
builder.append(line)
}.toString()
// 2
return Success(json)
}
} catch (ioe: IOException) {
// 3
return Error(FetcherException(ioe.localizedMessage))
}
}
}
The code is similar to before. But this time you:
- Defined
fetch()
withResult<FetcherException, String>
as a return type. - Returned a
Success<String>
that encapsulates the result in case of success. - Returned a
Error<FetcherException>
that encapsulates the exception in case of error.
Besides type safety and purity, what advantages will you receive by doing this? To better understand, you’ll need to implement some useful typeclasses, starting from the functor.
Implementing Result<E,T> as Functor
You can think of a typeclass as a definition that abstracts a common behavior between different data types. A functor is probably one of the most common. It provides a function map()
that, for Result<E,T>
, can have two different flavors if applied to the success value of the error.
To define this, create a ResultFunctor.kt into the typeclasses sub-module. Then copy the following code:
fun <E1, E2, T> Result<E1, T>.mapLeft(fn: (E1) -> E2): Result<E2, T> = when (this) {
is Success<T> -> this
is Error<E1> -> Error(fn(this.e))
}
The function you pass as a parameter has type (E1) -> E2
, and it has effect only in case Result<E,T>
is an Error<E>
. In case it’s a Success<T>
, mapLeft()
returns the same object.
You can follow the same approach for Success<T>
. Copy this code in the same file:
fun <E, T, R> Result<E, T>.mapRight(fn: (T) -> R): Result<E, R> = when (this) {
is Success<T> -> Success(fn(this.a))
is Error<E> -> this
}
In this case, the function you pass as a parameter has type (T) -> R
. mapRight()
applies the function, but only if the current Result<E,T>
is a Success<T>
. If it’s an Error<E>
, it returns the same object.
Because there are two different versions of the map()
function, this typeclass is also called a bifunctor. You can provide another version of this by adding the following code to the same file:
fun <E1, E2, T, R> Result<E1, T>.bimap(fe: (E1) -> E2, fs: (T) -> R): Result<E2, R> = when (this) {
is Success<T> -> Success(fs(this.a))
is Error<E1> -> Error(fe(this.e))
}
As you can see, the implementation of bimap()
has both the functions for the success and error cases as parameters.
Practicing with the Bifunctor
You can now go back to FunctionalFetcher.kt and replace the existing main()
with the following:
fun main() {
val ok_url = URL("https://jsonplaceholder.typicode.com/todos")
val error_url = URL("https://error_url.txt")
// 1
val printErrorFun = { ex: FetcherException -> println("Error with message ${ex.message}") }
// 2
val printString = { str: String -> print(str) }
// 3
FunctionalFetcher.fetch(error_url)
.bimap(printErrorFun, printString)
}
This is similar to before. But this time you:
- Defined
printErrorFun()
, which prints out the message of aFetcherException
. - Created
printString()
, which prints out the content fetched from the network as a simpleString
. - Used the two functions as a parameter for the
bimap()
you defined earlier.
Now, you can easily run the code and see the output for success or failure.
In case of success, you can print the text you receive from the network. It will look something like this:
[ { "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false }, { "userId": 1, "id": 2, "title": "quis ut nam facilis et officia qui",
- - -
{ false }]
In case of error, however, you’ll see this error message:
Error with message error_url.txt
Defining the Applicative Functor
The applicative is another important typeclass. It adds two new functions to the functor.
Start by creating a new ResultApplicative.kt in the typeclass submodule. Then copy the following code:
fun <T> justResult(value: T) = Success(value)
This code defines justResult()
. This is similar to the type constructor you saw in a previous tutorial. It’s a function that accepts a value of type T
and creates an instance of a specific data type — Result<E,T>
in this case.
You can typically find this function with the name just()
or pure()
. You use the justResult()
to avoid the usage of companion objects or extension functions and leverage the type inference Kotlin provides.
After this, you can copy the following code in the same file:
// 1
fun <E, T, R> Result<E, T>.ap(fn: Result<E, (T) -> R>): Result<E, R> = when (fn) {
// 2
is Success<(T) -> R> -> mapRight(fn.a)
is Error<E> -> when (this) {
// 3
is Success<T> -> Error(fn.e)
// 4
is Error<E> -> Error(this.e)
}
}
In this code, you define the ap()
function. It:
- Is similar to a functor. However, an important difference is the type of function parameter,
Result<E, (T) -> R>
. The function you want to apply is encapsulated into the same data type you’re creating as an applicative. - Applies the function you’re passing as a parameter of type
Result<E, (T) -> R>
, in case it’s aSuccess<(T)->R>
. Here, you invokemapRight()
, which already takes care of the case when the current object is anError<E>
. - Returns
Error<E>
to encapsulate the error of the parameter in case it’s anError<E>
. - Returns
Error<E>
to encapsulate the error into the current object in case it’s anError<E>
.
An example will help you to better understand how this works.
Setting the Applicative to Work
Let’s create an class called User
. You can define it by copying the following definition into a new User.kt. Create the following in the main src folder:
data class User(val id: Int, val name: String, val email: String)
This data class has three mandatory properties you must provide to have a valid user. To do so, add a builder function to User.kit:
val userBuilder = { id: Int -> { name: String -> { email: String -> User(id, name, email) } } }
The type of this function is:
typealias UserBuilder = (Int) -> (String) -> (String) -> User
User
constructor. You’ll learn about currying in a future tutorial.You can test userBuilder()
by copying and running the following code into the same file:
fun main() {
// 1
val idAp = justResult(1)
val nameAp = justResult("Max")
val emailAp = justResult("max@maxcarli.it")
// 2
val userAp = justResult(userBuilder)
// 3
emailAp.ap(nameAp.ap(idAp.ap(userAp))).mapRight {
println(it)
}
}
Here, you have:
- Encapsulated the value you want to use as parameters into a
Success
of the related type using thejustResult()
function. - Done the same for the
userBuilder()
function. - Invoked
ap()
on theSuccess
. For theid
to get anotherSuccess
, you pass one for thename
. Finally, you do the same for the one about theemail
.
The result is a Success<User>
. You know this is a functor you can call mapRight()
on. Because everything is in place, you’ll get the following output:
User(id=1, name=Max, email=max@maxcarli.it)
As a counter-example, you can change the code in main() with the following where the name is missing:
fun main() {
val idAp = justResult(1)
// 1
val missingNameAp = Error(IllegalStateException("Missing Name!"))
val emailAp = justResult("max@maxcarli.it")
val userAp = justResult(userBuilder)
// 2
emailAp.ap(missingNameAp.ap(idAp.ap(userAp))).mapLeft {
println(it)
}
}
With this code, you’ve:
- Created an
Error<IllegalStateException>
as value for the name. - Used
mapLeft()
to display the error message.
Build and run. You’ll see the following output:
java.lang.IllegalStateException: Missing name!
This means the User
has not been created because the mandatory name is missing.
At this point, you probably noticed that the syntax is not easy to write and wondering if a different approach may be better. You’re absolutely correct! And functional programming can help you devise better syntax.
Devising a Better Syntax For Applicatives
In the previous example, you had to deal with gaggles of parentheses and dots. Kotlin and functional programming can help you with that.
Add the following to ResultApplicative.kt in the typeclasses sub-module:
infix fun <E, A, B> Result<E, (A) -> B>.appl(a: Result<E, A>) = a.ap(this)
Here, you basically flip the receiver of the function with its argument. You can now replace the previous code for main()
in User.kt with the following:
fun main() {
val idAp = justResult(1)
val nameAp = justResult("Max")
val missingNameAp = Error(IllegalStateException("Missing name!"))
val emailAp = justResult("max@maxcarli.it")
val userAp = justResult(userBuilder)
// 1
(userAp appl idAp appl nameAp appl emailAp).mapRight { println(it) }
// 2
(userAp appl idAp appl missingNameAp appl emailAp).mapLeft { println(it) }
}
Here’s what’s happening in the code:
- Create the
User
and passing the values for its parameters in order. - Using the same code but getting an
Error<IllegalStateException>
in case any of the parameters are missing.
Run the code. You’ll get the following output for the different use cases:
User(id=1, name=Max, email=max@maxcarli.it)
java.lang.IllegalStateException: Missing name!
Using an Applicative for Validation
The previous code is good, but you can do better. For instance, you can add the following in UserValidation.kt in the main module. Create a file called UserValidation.kt and copy the following code in:
// 1
class ValidationException(msg: String) : Exception(msg)
// 2
fun validateName(name: String): Result<ValidationException, String> =
if (name.length > 4) Success(name) else Error(ValidationException("Invalid Name"))
// 3
fun validateEmail(email: String): Result<ValidationException, String> =
if (email.contains("@")) Success(email) else Error(ValidationException("Invalid email"))
With this code you’ve:
- Created
ValidationException
, which encapsulates a possible validation error message. - Defined
validateName()
to check if the name is longer than four characters. - Created
validateEmail()
to validate the email and check if it contains the@
symbol.
Both functions return a Error<ValidationException>
if the validation fails. You can now use them like in the following code.
Copy the following code into UserValidation.kt, then run the method.:
fun main() {
// 1
val idAp = justResult(1)
val userAp = justResult(userBuilder)
// 2
val validatedUser = userAp appl idAp appl validateName("Massimo") appl validateEmail("max@maxcarli.it")
// 3
validatedUser.bimap({
println("Error: $it")
}, {
println("Validated user: $it")
})
}
With this code, you’ve:
- Initialized
idAp
anduserAp
as before. - Used
validateName()
andvalidateEmail()
to validate the input parameter. - Printed the result using the
bimap()
you created earlier.
This code should now create the User
, but only if the validation is successful or an incorrect validation displays the error message.
Learning More About Errors With Semigroups
In the previous code, you learned a possible usage for the applicative typeclass using some validator functions. You can still improve how to manage validation error, though.
Create a new file named UserSemigroup.kt in the main module. Then copy and run the following code:
fun main() {
val idAp = justResult(1)
val userAp = justResult(userBuilder)
val validatedUser = userAp appl idAp appl validateName("Max") appl validateEmail("maxcarli.it")
validatedUser.bimap({
println("Error: $it")
}, {
println("Validated user: $it")
})
}
You’ll get the following output:
Error: com.raywenderlich.fp.ValidationException: Invalid email
Although both the validators fail, you only get the last error message. You lost the information about the first validation error for the length of the name. In cases like this, you can use semigroup typeclasses. Basically, these define how to combine the information encapsulated in the context of two data types.
To understand how this works, create a new file named ValidationSemigroup.kt in the typeclasses sub-module. Then copy the following code:
// 1
interface Semigroup<T> {
operator fun plus(rh: T): T
}
// 2
class SgValidationException(val messages: Array<String>) : Semigroup<SgValidationException> {
// 3
override operator fun plus(rh: SgValidationException) =
SgValidationException(this.messages + rh.messages)
}
With this code you’ve:
- Created
Semigroup<T>
, an interface that abstracts objects with theplus()
operation. - Defined a new
SgValidationException
to encapsulate an array of error messages and implementSemigroup<SgValidationException>
. This means you can add an instance ofSgValidationException
to another one. - Overloaded the + operator in a way that combines two instances of
SgValidationException
to create a new one with error messages that unit both.
You can now replace the implementation of ap()
into the ResultApplicative.kt file. Use the following:
// 1
fun <E : Semigroup<E>, T, R> Result<E, T>.ap(fn: Result<E, (T) -> R>): Result<E, R> = when (fn) {
is Success<(T) -> R> -> mapRight(fn.a)
is Error<E> -> when (this) {
is Success<T> -> Error(fn.e)
// 2
is Error<E> -> Error(this.e + fn.e)
}
}
Here you’ve:
- Added the constraint to the type
E
for being aSemigroup<E>
. - Combined the errors in case both the objects in play are
Error<E>
.
In the same file, you have to replace the current implementation for appl()
with the following:
infix fun <E : Semigroup<E>, A, B> Result<E, (A) -> B>.appl(a: Result<E, A>) = a.ap(this)
This will add the same constraints to the type E
and will break the main()
into the User.kt file. You can now replace that file with the following code, which uses Error<SgValidationException>
as error type:
fun main() {
val idAp = justResult(1)
val nameAp = justResult("Max")
val missingNameAp = Error(SgValidationException(arrayOf("Missing name!"))) // HERE
val emailAp = justResult("max@maxcarli.it")
val userAp = justResult(userBuilder)
// 1
(userAp appl idAp appl nameAp appl emailAp).mapRight { println(it) }
// 2
(userAp appl idAp appl missingNameAp appl emailAp).mapLeft { println(it) }
}
Next, replace validateName()
and validateEmail()
in UserValidation.kt with the following code:
fun validateName(name: String): Result<SgValidationException, String> =
if (name.length > 4) Success(name)
else Error(SgValidationException(arrayOf("Invalid Name")))
fun validateEmail(email: String): Result<SgValidationException, String> =
if (email.contains("@")) Success(email)
else Error(SgValidationException(arrayOf("Invalid email")))
Here, you’ve replaced ValidationException
with SgValidationException
, which needs an array of error messages. You can now test how it works by replacing main()
in UserValidation.kt with the following:
fun main() {
val idAp = justResult(1)
val userAp = justResult(userBuilder)
val validatedUser = userAp appl idAp appl validateName("Max") appl validateEmail("maxcarli.it")
validatedUser.bimap({
it.messages.forEach {
println("Error: $it")
}
}, {
println("Validated user: $it")
})
}
Because both the validations are not successful, you’ll get an output like this:
Error: Invalid email
Error: Invalid Name
As you can see, it contains both error messages. This is because SgValidationException
is a Semigroup
.
Presenting Monads
In the previous section, you implemented Result<E,A>
as a functor and applicative that you can use for the FunctionalFetcher. The result is a JSON you store as a type String. In practice, you need to parse the JSON and return an object of a different type.
Create a file named FunctionalFetcherApp.kt into the main module. Then copy the following code:
object FunctionalFetcherResult {
// 1
fun fetch(url: URL): Result<FetcherException, String> {
try {
with(url.openConnection() as HttpURLConnection) {
requestMethod = "GET"
val reader = inputStream.bufferedReader()
val result = reader.lines()
.asSequence().fold(StringBuilder()) { builder, line ->
builder.append(line)
}.toString()
// 2
return Success(result)
}
} catch (ioe: IOException) {
// 3
return Error(FetcherException(ioe.localizedMessage))
}
}
}
This should be nothing new. You’ve simply:
- Defined
fetch()
using theResult<FetcherException,String>
as a return type. - Returned a
Success<String>
if you successfully receive some data. - Returned an
Error<FetcherException>
in case of an error.
In case of success, you’ll get a text containing the JSON. It will look like this:
[
{
"userId":1,
"id":1,
"title":"delectus aut autem",
"completed":false
},
- - -
]
It contains the JSON for an array of items that you can represent with the following data class, which you can copy in the same Kotlin file. It also represents a Task in an application for your TODOs.
@Serializable
data class Task(val userId: Int, val id: Int, val title: String, val completed: Boolean)
You now need to parse JSON into a List<Task>
. You can do this with the following function copied into the same file:
fun parseTasks(jsonData: String): Result<JsonDecodingException, List<Task>> {
val json = Json(JsonConfiguration.Stable)
try {
return Success(json.parse(Task.serializer().list, jsonData))
} catch (ex: JsonDecodingException) {
return Error(ex)
}
}
This code tries to parse String
in input. It returns a Success<List<Task>>
in case of success or an Error<JsonDecodingException>
in case of error.
Suppose you want to use this function to parse the result of your FunctionalFetcherResult
object. You can then copy the following code into the same FunctionalFetcherApp.kt:
fun main() {
val ok_url = URL("https://jsonplaceholder.typicode.com/todos")
val error_url = URL("https://error_url.txt")
// 1
FunctionalFetcherResult.fetch(ok_url).mapRight {
// 2
parseTasks(it)
}.mapLeft {
// 3
println("Error $it")
}.mapRight {
// 4
println("Output $it")
}
}
In this code you’ve:
- Invoked
fetch()
with theok_url
. - Used
parseTasks()
as a parameter tomap()
. - Printed the error message in case of an error.
- Printed the result in case of success.
Build and run. In case of success, you’ll get an output like the following:
Output com.raywenderlich.fp.Success@131276c2
This is because of the parseTasks()
in mapRight
. It returns a Result<JsonDecodingException, List<Task>>
, which is the one you print in the end. In case of success, it would be nice to have List<Task>
directly as an implicit parameter for the last mapRight
. This is where the monad comes in.
Creating the Result<E, T> Monad
Crete a new ResultMonad.kt in the typeclass sub-module. Copy the following code:
// 1
fun <E, T, R> Result<E, T>.flatMap(fn: (T) -> Result<E, R>): Result<E, R> = when (this) {
// 2
is Success<T> -> fn(this.a)
// 3
is Error<E> -> this
}
Here you define a monad typeclass with flatMap()
. This function:
- Has a parameter of type
(T) -> Result<E, R>
. - Returns the result of the function you get as parameter on the current value. In this case, the current object is a
Success<T>
. - Returns the same object in case of an
Error<E>
.
Now you can replace main()
in FunctionalFetcherApp.kt with the following:
fun main() {
val ok_url = URL("https://jsonplaceholder.typicode.com/todos")
val error_url = URL("https://error_url.txt")
FunctionalFetcherResult.fetch(ok_url)
.flatMap(::parseTasks) // HERE
.mapLeft {
println("Error $it")
}.mapRight {
println("Output $it")
}
}
You just replaced the first mapRight()
invocation with the flatMap()
you just define. Build and run.
If successful, you’ll now get an output like this:
Output [Task(userId=1, id=1, title=delectus aut autem, completed=false), Task(userId=1, id=2, title=quis ut nam facilis et officia qui, completed=false),
- - -
Task(userId=10, id=200, title=ipsam aperiam voluptates qui, completed=false)]
Now, if successful, the last mapRight()
receives the result of parseTasks()
, a function that can fail. In that case, you’d still get the JsonDecodingException
as the type of the implicit parameter for mapLeft()
.
Where to Go From Here?
Congratulations! You just wrote a bunch of code. You implemented a Result<E, T>
data type, and you handled the superpower of a bifunctor, an applicative and a monad. You also had the chance to use the semigroup typeclass in case of error management.
Your journey through the world of functional programming doesn’t end here, though. In the next tutorial, you’ll learn how Arrow can help generate most of the code you just wrote from scratch. You’ll have the chance to work on this same project and see how Arrow can help with the implementation of the FunctionalFetcher use case.
You can download a complete project using the Download Materials button at the top or bottom of this tutorial.
If you have any comments or questions, feel free to join in the forum below.
Comments