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.

The module offers APIs tailored for comparing existing values of your data types. Alternatively, it provides mechanisms for defining constructors for your data types.

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

In this example, we use the Data.struct function to create structured data objects and check their equality using Equal.equals.

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

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.

Constructors

The module introduces a concept known as "Case classes", which automate various essential operations when defining data types. These operations include generating constructors, handling equality checks, and managing hashing.

Case classes can be defined in two primary ways:

  • as plain objects using case or tagged
  • as TypeScript classes using Class or TaggedClass

case

This helper 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 `Person`
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 `Person`
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 create a constructor for Person using Data.case. The resulting instances come with built-in equality checks, making it simple to compare them using Equal.equals.

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: { name: 'Mike', _tag: '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: { name: 'Mike', _tag: 'Person' }

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. 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

TaggedClass

For those who prefer working with classes over plain objects, you can utilize Data.TaggedClass as an alternative to tagged.

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" })
 
console.log(mike1) // Output: Person { name: 'Mike', _tag: 'Person' }
 
// 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.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" })
 
console.log(mike1) // Output: Person { name: 'Mike', _tag: 'Person' }
 
// Checking equality
console.log(Equal.equals(mike1, mike2)) // Output: true
console.log(Equal.equals(mike1, john)) // Output: false

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

Union of Tagged Structs

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

Definition

Let's walk through an example to see how this works:

ts
import { Data, Equal } from "effect"
 
// Define a union type using TaggedEnum
type RemoteData = Data.TaggedEnum<{
Loading: {}
Success: { readonly data: string }
Failure: { readonly reason: string }
}>
 
// Create constructors for specific error types
const { Loading, Success, Failure } = Data.taggedEnum<RemoteData>()
 
// Create instances of errors
const state1 = Loading()
const state2 = Success({ data: "test" })
const state3 = Success({ data: "test" })
const state4 = Failure({ reason: "not found" })
 
// Checking equality
console.log(Equal.equals(state2, state3)) // Output: true
console.log(Equal.equals(state2, state4)) // Output: false
 
console.log(state1) // Output: { _tag: 'Loading' }
console.log(state2) // Output: { data: 'test', _tag: 'Success' }
console.log(state4) // Output: { reason: 'not found', _tag: 'Failure' }
ts
import { Data, Equal } from "effect"
 
// Define a union type using TaggedEnum
type RemoteData = Data.TaggedEnum<{
Loading: {}
Success: { readonly data: string }
Failure: { readonly reason: string }
}>
 
// Create constructors for specific error types
const { Loading, Success, Failure } = Data.taggedEnum<RemoteData>()
 
// Create instances of errors
const state1 = Loading()
const state2 = Success({ data: "test" })
const state3 = Success({ data: "test" })
const state4 = Failure({ reason: "not found" })
 
// Checking equality
console.log(Equal.equals(state2, state3)) // Output: true
console.log(Equal.equals(state2, state4)) // Output: false
 
console.log(state1) // Output: { _tag: 'Loading' }
console.log(state2) // Output: { data: 'test', _tag: 'Success' }
console.log(state4) // Output: { reason: 'not found', _tag: 'Failure' }

In this example:

  • We define a RemoteData union type with three states: Loading, Success, and Failure.
  • We use Data.taggedEnum to create constructors for these states.
  • We create instances of each state and check for equality using Equal.equals.

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

Adding Generics

You can also create tagged unions with generics using TaggedEnum.WithGenerics. This allows for more flexible and reusable type definitions.

ts
import { Data } from "effect"
 
type RemoteData<Success, Failure> = Data.TaggedEnum<{
Loading: {}
Success: { data: Success }
Failure: { reason: Failure }
}>
 
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({ reason: "not found" })
 
const success = Success({ data: 1 })
ts
import { Data } from "effect"
 
type RemoteData<Success, Failure> = Data.TaggedEnum<{
Loading: {}
Success: { data: Success }
Failure: { reason: Failure }
}>
 
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({ reason: "not found" })
 
const success = Success({ data: 1 })

$is and $match

The Data.taggedEnum also provides $is and $match functions for type guards and pattern matching, respectively.

ts
import { Data } from "effect"
 
type RemoteData = Data.TaggedEnum<{
Loading: {}
Success: { readonly data: string }
Failure: { readonly reason: string }
}>
 
const { $is, $match, Loading, Success, Failure } =
Data.taggedEnum<RemoteData>()
 
// Create a type guard
const isLoading = $is("Loading")
 
console.log(isLoading(Loading())) // true
console.log(isLoading(Success({ data: "test" }))) // false
 
// Create a matcher
const matcher = $match({
Loading: () => "this is a Loading",
Success: ({ data }) => `this is a Success: ${data}`,
Failure: ({ reason }) => `this is a Failre: ${reason}`
})
 
console.log(matcher(Success({ data: "test" }))) // "this is a Success: test"
ts
import { Data } from "effect"
 
type RemoteData = Data.TaggedEnum<{
Loading: {}
Success: { readonly data: string }
Failure: { readonly reason: string }
}>
 
const { $is, $match, Loading, Success, Failure } =
Data.taggedEnum<RemoteData>()
 
// Create a type guard
const isLoading = $is("Loading")
 
console.log(isLoading(Loading())) // true
console.log(isLoading(Success({ data: "test" }))) // false
 
// Create a matcher
const matcher = $match({
Loading: () => "this is a Loading",
Success: ({ data }) => `this is a Success: ${data}`,
Failure: ({ reason }) => `this is a Failre: ${reason}`
})
 
console.log(matcher(Success({ data: "test" }))) // "this is a Success: test"

Errors

In Effect, errors play a crucial role, and defining and constructing them is made easier with two specialized constructors:

  • Error
  • TaggedError

Error

With Data.Error, we can create an Error with additional fields beyond the usual message:

ts
import { Data } from "effect"
 
class NotFound extends Data.Error<{ message: string; file: string }> {}
 
const err = new NotFound({
message: "Cannot find this file",
file: "foo.txt"
})
 
console.log(err instanceof Error) // Output: true
 
console.log(err.file) // Output: foo.txt
console.log(err)
/*
Output:
Error: Cannot find this file
... stack trace ...
*/
ts
import { Data } from "effect"
 
class NotFound extends Data.Error<{ message: string; file: string }> {}
 
const err = new NotFound({
message: "Cannot find this file",
file: "foo.txt"
})
 
console.log(err instanceof Error) // Output: true
 
console.log(err.file) // Output: foo.txt
console.log(err)
/*
Output:
Error: Cannot find this file
... stack trace ...
*/

Additionally, NotFound is "yieldable" as it is an Effect, so there's no need to use Effect.fail:

ts
import { Data, Effect } from "effect"
 
class NotFound extends Data.Error<{ message: string; file: string }> {}
 
const program = Effect.gen(function* () {
yield* new NotFound({
message: "Cannot find this file",
file: "foo.txt"
})
})
ts
import { Data, Effect } from "effect"
 
class NotFound extends Data.Error<{ message: string; file: string }> {}
 
const program = Effect.gen(function* () {
yield* new NotFound({
message: "Cannot find this file",
file: "foo.txt"
})
})

TaggedError

In Effect, there's a special convention to add a _tag field to custom errors. This convention simplifies certain operations, such as error handling with APIs like Effect.catchTag or Effect.catchTags. Therefore, the TaggedError API simplifies the process of creating custom errors by automatically adding this type of tag without needing to specify it every time you create a new error:

ts
import { Data, Effect, Console } from "effect"
 
class NotFound extends Data.TaggedError("NotFound")<{
message: string
file: string
}> {}
 
const program = Effect.gen(function* () {
yield* new NotFound({
message: "Cannot find this file",
file: "foo.txt"
})
}).pipe(
Effect.catchTag("NotFound", (err) =>
Console.error(`${err.message} (${err.file})`)
)
)
 
Effect.runPromise(program)
// Output: Cannot find this file (foo.txt)
ts
import { Data, Effect, Console } from "effect"
 
class NotFound extends Data.TaggedError("NotFound")<{
message: string
file: string
}> {}
 
const program = Effect.gen(function* () {
yield* new NotFound({
message: "Cannot find this file",
file: "foo.txt"
})
}).pipe(
Effect.catchTag("NotFound", (err) =>
Console.error(`${err.message} (${err.file})`)
)
)
 
Effect.runPromise(program)
// Output: Cannot find this file (foo.txt)