In the previous article, we introduced Effect-TS, a library designed to tackle common challenges in application development, such as handling complex side effects, managing concurrency, and error handling. Effect-TS is ideal for developers who seek robust applications with a focus on consistency, type safety, and advanced concurrency, all within a functional programming paradigm. We briefly discussed the basics of effects, including their type and how to create and execute them.
In this post, we will delve deeper into creating and executing effects, providing an example to build your confidence in using Effect-TS.
Unlike standard TypeScript functions, effects in Effect-TS are neither inherently synchronous nor asynchronous; they simply describe computations that can either succeed or result in errors.
Let's first start with
Effects with Synchronous computations
:
Synchronous computations execute sequentially, blocking further execution until they complete, producing immediate and deterministic results.
In Effect-TS, you can represent synchronous computations using Effect.sync
.
const generateRandomNumber = Effect.sync(() => Math.random());
Effect.sync
always returns a success value. If it throws an error, that indicates a defect rather than a typical effect failure. Therefore, use Effect.sync only when you are certain that your operation will not fail
Executing generateRandomNumber directly will not yield the desired result, as effects are lazy and defer execution until required. To execute the effect and obtain the computation's result, use:
Effect.runSync(generateRandomNumber)
This will execute the sync effect immediately.
When you know that your operation might fail, use Effect.try
. This structure allows you to handle exceptions functionally:
const parseJson = (jsonString: string): Effect.Effect<unknown, Error, unknown> =>
Effect.try({
try: () => JSON.parse(jsonString),
catch: (error) => new Error(`Failed to parse JSON: ${(error as Error).message}`),
});
To execute this effect, run it with Effect.runSync
:
const inputString ='{"name": "Alice", "age": 30}';
try {
Effect.runSync(parseJson(inputString));
} catch (error) {
console.error(error);
}
This executes the effect synchronously, catching and logging any parsing errors.
Effects with Asynchronous computations
:
Asynchronous computations involve tasks that execute independently of the main program flow, allowing other operations to proceed while waiting for a result.
In Effect-TS, asynchronous computations are managed using the Effect.tryPromise
function. This function wraps an asynchronous operation, such as a Promise, into an Effect, allowing you to handle asynchronous processes in a functional, type-safe manner.
const fetchData = (url: string): Effect.Effect<unknown, Error, Response> =>
Effect.tryPromise({
try: () => fetch(url),
catch: (error) => new Error(`Failed to fetch data: ${(error as Error).message}`),
});
To execute an asynchronous effect, you use Effect.runPromise
, which handles the Promise-based nature of the operation:
const url = "https://api.example.com/data";
Effect.runPromise(fetchData(url))
.then(data => console.log(data))
.catch(error => console.error(error));
Avoid Executing Effects Within Other Effects
:
Always aim to execute effects at the boundaries of your program, such as in main functions. Running effects inside other effects negates the benefits of using an effect system, as it blurs the clear separation between effect description and execution. By maintaining this separation, you ensure that your program remains modular, predictable, and easier to test and maintain.
Combinators: Combining, Transforming, and Managing Effects
Combinators provide a framework for creating expressive and modular effect descriptions without executing them. By avoiding the nesting of effects and using combinators instead, you leverage the full power of effect systems, aligning with functional programming principles.
Instead of executing effects within other effects:
const initialEffect = Effect.succeed(4);
const transformedEffect = Effect.sync(() => {
const result = Effect.runSync(initialEffect);
return result * 2;
});
You can use combinators to maintain separation for e.g.
Effect.map
: This takes an effect and lets you change the result it produces using a simple function.
const initialEffect = Effect.succeed(4);
const transformedEffect = Effect.map(initialEffect, x => x * 2);
This transformation does not execute the effect; it only modifies the outcome once the effect is run.
Effect.flatMap
: It takes the output of an effect, which produces another effect, and flattens the result. This is commonly used when need to perform sequential computations that result in an effect, allowing the next computation to depend on the value produced by the first effect.
const initialEffect = Effect.succeed(10);
const chainedEffect = Effect.flatMap(initialEffect, x => Effect.succeed(x * 2));
pipe
: It allows chaining functions in a left-to-right manner, increasing readability
const pipeline = pipe(
Effect.succeed(10),
Effect.map(x => x + 5),
Effect.map(x => x * 2)
);
and there are many more available to help you handle different scenarios effectively....
Let's build an application called dataTransformer to download data from an S3 bucket, transform it, and then re-upload it back to S3. This example will provide a practical understanding of Effect-TS in action.
First, we create an S3 client and define the functions needed for our tasks:
import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { Effect, pipe } from 'effect';
const s3Client =
new S3Client({
region: "your-region",
forcePathStyle: true,
});
const downloadData = (bucket: string, key: string): Effect.Effect<string, Error> =>
Effect.tryPromise({
try: async () => {
const response = await s3Client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
return response.Body?.transformToString() ?? "";
},
catch: (error) => new Error(`Failed to download data: ${error.message}`),
});
const transformData = (data: string) =>
Effect.sync(() => {
return data.toUpperCase();
});
const uploadData = (bucket: string, key: string, data: string) =>
Effect.tryPromise({
try: () =>
s3Client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: data,
})
),
catch: (error) => new Error(`Failed to upload data: ${error.message}`),
});
Next, we compose the transformer function using Effect-TS combinators:
const dataTransformer = (sourceBucket: string, sourceKey: string, targetBucket: string, targetKey: string) =>
pipe(
downloadData(sourceBucket, sourceKey),
Effect.flatMap(transformData),
Effect.andThen((transformedData) => uploadData(targetBucket, targetKey, transformedData))
);
Finally, we define the main function to execute the effectful computation:
Effect.runPromise(dataTransformer("bucket-name", "key", "target-bucket-name", "target-key"))
.then(() => console.log("Data processed successfully"))
.catch(console.error);
I hope we are now clear on the creation and execution of effects. Let's continue in the next part, where we'll explore efficient error handling, concurrency management, effect data types, resource management, and much more. Stay tuned!
Top comments (0)