Data

On this page

The Data module offers a range of features that make it easier to create and manipulate data structures in your TypeScript applications. It includes functionalities for defining data types, ensuring equality between data objects, and hashing data for efficient comparison.

Value Equality

If you need to compare existing values for equality without the need for explicit implementations, consider using the Data module. It provides convenient APIs that generate default implementations for Equal and Hash, making equality checks a breeze.

struct

ts
import { Data, Equal } from "effect"
 
const alice = Data.struct({ name: "Alice", age: 30 })
 
const bob = Data.struct({ name: "Bob", age: 40 })
 
console.log(Equal.equals(alice, alice)) // Output: true
console.log(Equal.equals(alice, Data.struct({ name: "Alice", age: 30 }))) // Output: true
 
console.log(Equal.equals(alice, { name: "Alice", age: 30 })) // Output: false
console.log(Equal.equals(alice, bob)) // Output: false
ts
import { Data, Equal } from "effect"
 
const alice = Data.struct({ name: "Alice", age: 30 })
 
const bob = Data.struct({ name: "Bob", age: 40 })
 
console.log(Equal.equals(alice, alice)) // Output: true
console.log(Equal.equals(alice, Data.struct({ name: "Alice", age: 30 }))) // Output: true
 
console.log(Equal.equals(alice, { name: "Alice", age: 30 })) // Output: false
console.log(Equal.equals(alice, bob)) // Output: false

In this example, we use the Data.struct function to create structured data objects and check their equality using Equal.equals(). The Data module simplifies the process by providing a default implementation for both Equal and Hash, allowing you to focus on comparing values without the need for explicit implementations.

tuple

If you prefer to model your domain with tuples, the Data.tuple function has got you covered:

ts
import { Data, Equal } from "effect"
 
const alice = Data.tuple("Alice", 30)
 
const bob = Data.tuple("Bob", 40)
 
console.log(Equal.equals(alice, alice)) // Output: true
console.log(Equal.equals(alice, Data.tuple("Alice", 30))) // Output: true
 
console.log(Equal.equals(alice, ["Alice", 30])) // Output: false
console.log(Equal.equals(alice, bob)) // Output: false
ts
import { Data, Equal } from "effect"
 
const alice = Data.tuple("Alice", 30)
 
const bob = Data.tuple("Bob", 40)
 
console.log(Equal.equals(alice, alice)) // Output: true
console.log(Equal.equals(alice, Data.tuple("Alice", 30))) // Output: true
 
console.log(Equal.equals(alice, ["Alice", 30])) // Output: false
console.log(Equal.equals(alice, bob)) // Output: false

array

You can take it a step further and use arrays to compare multiple values:

ts
import { Data, Equal } from "effect"
 
const alice = Data.struct({ name: "Alice", age: 30 })
const bob = Data.struct({ name: "Bob", age: 40 })
 
const persons = Data.array([alice, bob])
 
console.log(
Equal.equals(
persons,
Data.array([
Data.struct({ name: "Alice", age: 30 }),
Data.struct({ name: "Bob", age: 40 })
])
)
) // Output: true
ts
import { Data, Equal } from "effect"
 
const alice = Data.struct({ name: "Alice", age: 30 })
const bob = Data.struct({ name: "Bob", age: 40 })
 
const persons = Data.array([alice, bob])
 
console.log(
Equal.equals(
persons,
Data.array([
Data.struct({ name: "Alice", age: 30 }),
Data.struct({ name: "Bob", age: 40 })
])
)
) // Output: true

In this extended example, we create an array of person objects using the Data.array function. We then compare this array with another array of person objects using Equal.equals(), and the result is true since the arrays contain structurally equal elements.

Case Classes

The module introduces the concept of "Case" classes. Case classes are a feature introduced by this module that automates several critical operations when creating data types. These operations include generating constructors, handling equality checks, and managing hashing.

Case classes can be defined in two main ways: as structs using Case, case, and tagged, or as classes using Class or TaggedClass.

case

Let's start by creating a case class using Case and case. This combination automatically provides implementations for constructors, equality checks, and hashing for your data type.

ts
import { Data, Equal } from "effect"
 
interface Person {
readonly name: string
}
 
// Creating a constructor for the specified Case
const Person = Data.case<Person>()
 
// Creating instances of Person
const mike1 = Person({ name: "Mike" })
const mike2 = Person({ name: "Mike" })
const john = Person({ name: "John" })
 
// Checking equality
console.log(Equal.equals(mike1, mike2)) // Output: true
console.log(Equal.equals(mike1, john)) // Output: false
ts
import { Data, Equal } from "effect"
 
interface Person {
readonly name: string
}
 
// Creating a constructor for the specified Case
const Person = Data.case<Person>()
 
// Creating instances of Person
const mike1 = Person({ name: "Mike" })
const mike2 = Person({ name: "Mike" })
const john = Person({ name: "John" })
 
// Checking equality
console.log(Equal.equals(mike1, mike2)) // Output: true
console.log(Equal.equals(mike1, john)) // Output: false

Here, we define a Person data type, we then create a constructor for Person using Data.case. The resulting Person instances come with built-in equality checks thanks to the Data module, making it simple to compare them using Equal.equals.

If you prefer working with classes instead of plain objects, you can explore the use of Data.Class.

tagged

In certain situations, like when you're defining a data type that includes a tag field (commonly used in disjoint unions), using the case approach can become repetitive and cumbersome. This is because you're required to specify the tag every time you create an instance:

ts
import { Data } from "effect"
 
interface Person {
readonly _tag: "Person" // the tag
readonly name: string
}
 
const Person = Data.case<Person>()
 
// It can be quite frustrating to repeat `_tag: 'Person'` every time...
const mike = Person({ _tag: "Person", name: "Mike" })
const john = Person({ _tag: "Person", name: "John" })
ts
import { Data } from "effect"
 
interface Person {
readonly _tag: "Person" // the tag
readonly name: string
}
 
const Person = Data.case<Person>()
 
// It can be quite frustrating to repeat `_tag: 'Person'` every time...
const mike = Person({ _tag: "Person", name: "Mike" })
const john = Person({ _tag: "Person", name: "John" })

To make your life easier, the tagged helper simplifies this process by allowing you to define the tag only once. It follows the convention within the Effect ecosystem of naming the tag field with "_tag":

ts
import { Data } from "effect"
 
interface Person {
readonly _tag: "Person" // the tag
readonly name: string
}
 
const Person = Data.tagged<Person>("Person")
 
// Now, it's much more convenient...
const mike = Person({ name: "Mike" })
const john = Person({ name: "John" })
 
console.log(mike._tag) // Output: "Person"
ts
import { Data } from "effect"
 
interface Person {
readonly _tag: "Person" // the tag
readonly name: string
}
 
const Person = Data.tagged<Person>("Person")
 
// Now, it's much more convenient...
const mike = Person({ name: "Mike" })
const john = Person({ name: "John" })
 
console.log(mike._tag) // Output: "Person"

This approach significantly reduces redundancy and improves code readability when working with tagged data types.

If you prefer working with classes instead of plain objects, you can explore the use of Data.TaggedClass.

Class

If you find it more comfortable to work with classes instead of plain objects, you have the option to use Data.Class instead of Case and case. This approach can be particularly useful in scenarios where you prefer a more class-oriented structure:

ts
import { Data, Equal } from "effect"
 
class Person extends Data.Class<{ name: string }> {}
 
// Creating instances of Person
const mike1 = new Person({ name: "Mike" })
const mike2 = new Person({ name: "Mike" })
const john = new Person({ name: "John" })
 
// Checking equality
console.log(Equal.equals(mike1, mike2)) // Output: true
console.log(Equal.equals(mike1, john)) // Output: false
ts
import { Data, Equal } from "effect"
 
class Person extends Data.Class<{ name: string }> {}
 
// Creating instances of Person
const mike1 = new Person({ name: "Mike" })
const mike2 = new Person({ name: "Mike" })
const john = new Person({ name: "John" })
 
// Checking equality
console.log(Equal.equals(mike1, mike2)) // Output: true
console.log(Equal.equals(mike1, john)) // Output: false

One advantage of using classes is that you can easily add custom getters and methods to the class definition, enhancing its functionality to suit your specific needs:

ts
import { Data } from "effect"
 
class Person extends Data.Class<{ name: string }> {
get upperName() {
return this.name.toUpperCase()
}
}
 
const mike = new Person({ name: "Mike" })
 
console.log(mike.upperName) // Output: MIKE
ts
import { Data } from "effect"
 
class Person extends Data.Class<{ name: string }> {
get upperName() {
return this.name.toUpperCase()
}
}
 
const mike = new Person({ name: "Mike" })
 
console.log(mike.upperName) // Output: MIKE

By incorporating custom methods like upperName, you can extend the capabilities of your data class to perform various operations tailored to your application requirements.

TaggedClass

For those who prefer working with classes over plain objects, you can utilize Data.TaggedClass as an alternative to Case and tagged. This approach can be especially beneficial when you want to structure your data using class-based syntax:

ts
import { Data, Equal } from "effect"
 
class Person extends Data.TaggedClass("Person")<{ name: string }> {}
 
// Creating instances of Person
const mike1 = new Person({ name: "Mike" })
const mike2 = new Person({ name: "Mike" })
const john = new Person({ name: "John" })
 
// Checking equality
console.log(Equal.equals(mike1, mike2)) // Output: true
console.log(Equal.equals(mike1, john)) // Output: false
 
console.log(mike1._tag) // Output: "Person"
ts
import { Data, Equal } from "effect"
 
class Person extends Data.TaggedClass("Person")<{ name: string }> {}
 
// Creating instances of Person
const mike1 = new Person({ name: "Mike" })
const mike2 = new Person({ name: "Mike" })
const john = new Person({ name: "John" })
 
// Checking equality
console.log(Equal.equals(mike1, mike2)) // Output: true
console.log(Equal.equals(mike1, john)) // Output: false
 
console.log(mike1._tag) // Output: "Person"

One of the advantages of using tagged classes is that you can seamlessly incorporate custom getters and methods into the class definition, expanding its functionality as needed:

ts
import { Data } from "effect"
 
class Person extends Data.TaggedClass("Person")<{ name: string }> {
get upperName() {
return this.name.toUpperCase()
}
}
 
const mike = new Person({ name: "Mike" })
 
console.log(mike.upperName) // Output: MIKE
ts
import { Data } from "effect"
 
class Person extends Data.TaggedClass("Person")<{ name: string }> {
get upperName() {
return this.name.toUpperCase()
}
}
 
const mike = new Person({ name: "Mike" })
 
console.log(mike.upperName) // Output: MIKE

By introducing custom getters such as upperName, you can extend the capabilities of your tagged class to suit your specific application requirements.

Unions of Case Classes

If you're looking to create a disjoint union of tagged case classes, you can easily achieve this using Data.TaggedEnum. This feature simplifies the process of defining and working with unions.

Let's walk through an example:

ts
import { Data, Equal } from "effect"
 
// Define a union type using TaggedEnum
type HttpError = Data.TaggedEnum<{
InternalServerError: { reason: string }
NotFound: {}
}>
 
// Create constructors for specific error types
const { NotFound, InternalServerError } = Data.taggedEnum<HttpError>()
 
// Create instances of errors
const error1 = InternalServerError({ reason: "test" })
const error2 = InternalServerError({ reason: "test" })
const error3 = NotFound()
 
// Checking equality
console.log(Equal.equals(error1, error2)) // Output: true
console.log(Equal.equals(error1, error3)) // Output: false
 
console.log(error1._tag) // Output: "InternalServerError"
console.log(error3._tag) // Output: "NotFound"
ts
import { Data, Equal } from "effect"
 
// Define a union type using TaggedEnum
type HttpError = Data.TaggedEnum<{
InternalServerError: { reason: string }
NotFound: {}
}>
 
// Create constructors for specific error types
const { NotFound, InternalServerError } = Data.taggedEnum<HttpError>()
 
// Create instances of errors
const error1 = InternalServerError({ reason: "test" })
const error2 = InternalServerError({ reason: "test" })
const error3 = NotFound()
 
// Checking equality
console.log(Equal.equals(error1, error2)) // Output: true
console.log(Equal.equals(error1, error3)) // Output: false
 
console.log(error1._tag) // Output: "InternalServerError"
console.log(error3._tag) // Output: "NotFound"

Note that it follows the convention within the Effect ecosystem of naming the tag field with "_tag".

You can also pass a TaggedEnum.WithGenerics if you want to add generics to the constructors:

ts
import { Data } from "effect"
 
type RemoteData<A, B> = Data.TaggedEnum<{
Loading: {}
Success: { data: A }
Failure: { error: B }
}>
 
interface RemoteDataDefinition extends Data.TaggedEnum.WithGenerics<2> {
readonly taggedEnum: RemoteData<this["A"], this["B"]>
}
 
const { Loading, Failure, Success } = Data.taggedEnum<RemoteDataDefinition>()
 
const loading = Loading()
 
const failure = Failure({ error: "err" })
 
const success = Success({ data: 1 })
ts
import { Data } from "effect"
 
type RemoteData<A, B> = Data.TaggedEnum<{
Loading: {}
Success: { data: A }
Failure: { error: B }
}>
 
interface RemoteDataDefinition extends Data.TaggedEnum.WithGenerics<2> {
readonly taggedEnum: RemoteData<this["A"], this["B"]>
}
 
const { Loading, Failure, Success } = Data.taggedEnum<RemoteDataDefinition>()
 
const loading = Loading()
 
const failure = Failure({ error: "err" })
 
const success = Success({ data: 1 })