Effect.fail
Sometimes things go wrong. In JavaScript, where lots of work involves network communication, things go wrong quite often. Effect.fail
helps us know the kinds of things that can go wrong, so that we can do something about it. Choosing to do nothing is a valid option, but it should be a conscious choice.
Before we look at Effect.fail
in detail, here’s a quick test. This program safely passes the TypeScript type checker. Which inputs need to be commented out in order for this to run successfully?
const reformat = (timestamp: string) => {
const parsed = Date.parse(timestamp);
const date = new Date(parsed);
return date.toISOString();
};
const inputs = [
"2000-01-01", // obviously right
"2000", // this is also fine
"toaster", // this obviously goes wrong, but how?
"", // I hope this doesn't get turned into 0
"1300", // can JavaScript deal with times before 1970? how?
"0",
"-1",
"100000-01-01", // what about the distant future?
"200000-01-01",
"300000-01-01",
"400000-01-01",
];
inputs.forEach(input => {
console.log("trying", input)
console.log(reformat(input))
})
It turns out that time in the past is OK, at least for positive numbers, but that the future runs out some time in the year 275760. For "0"
and "-1"
the code returns a valid result, but it is perhaps not the one we would want. In particular, reformat("-1") > reformat("0")
.
It’s not surprising that this code throws an error for "toaster"
, but the nature of the error is more surprising. JavaScript throws a RangeError
, the same error we see for "400000-01-01"
.
const parsed = Date.parse(timestamp) // NaN
const date = new Date(NaN) // NaN
return date.toISOString() // RangeError: Invalid time value
Errors like this can be very frustrating. The dynamic nature of JavaScript means that TypeScript cannot infer any information about things that can go wrong. Even if we, the authors of the reformat
function, know that it can throw a RangeError
, the best we can do is document it.
/**
* @throws {RangeError}
*/
const reformat = (timestamp: string) => {
//...
}
try {
reformat("toaster")
} catch (e) {
// e is unknown or any, depending on tsconfig
console.error(e)
}
TypeScript is not able to do anything with this information, because, well, there might be less well-documented functions being called in the same scope. throw "foo"
is entirely valid code, meaning that within a catch
block we cannot even be sure it was an Error
that was thrown.
Handling errors in plain JavaScript is hard, which means that we often forget to do it entirely. One of the core aims of Effect (as an ecosystem) is to help developers write production-grade, fault-tolerant systems. One way it does this is by making error-handling simpler to reason about. This makes it more likely that developers will write appropriate error handling logic.
import { Effect } from "effect"
class InvalidDate extends Error {}
console.log("defining effects...")
// const millennium: Effect.Effect<Date, never, never>
const millennium = Effect.succeed(new Date(Date.parse("2000")))
// const toaster: Effect.Effect<never, InvalidDate, never>
const toaster = Effect.fail(new InvalidDate("toaster is not a valid timestamp"))
console.log("running effects...")
console.log(Effect.runSync(millennium))
console.log(Effect.runSync(toaster))
Recall that the first generic parameter tells us the type of value that this Effect holds. The type of millennium
tells us that this effect holds a value of type Date
. The type of toaster
tells us that this effect holds a value of type never
.
We now see the second generic parameter populated too. This tells us all the known ways that the Effect can go wrong. Our millennium
can never go wrong, but toaster
can (and indeed will) fail with an instance of InvalidDate
.
We can read Effect.Effect<A, E>
as “an effect of A, but may fail with E”.
When we run this program, we see that the script fails on the final line. Note that no errors occur when we define the effects, only when we run them.
$ npx tsx main.ts
defining effects...
running effects...
2000-01-01T00:00:00.000Z
node:internal/process/esm_loader:34
internalBinding('errors').triggerUncaughtException(
^
Error: toaster is not a valid timestamp
at <anonymous> (/home/fred/dev/learn-effect/main.ts:11:2)
at ModuleJob.run (node:internal/modules/esm/module_job:218:25)
at async ModuleLoader.import (node:internal/modules/esm/loader:329:24)
at async loadESM (node:internal/process/esm_loader:28:7)
at async handleMainPromise (node:internal/modules/run_main:113:12) {
name: '(FiberFailure) Error',
[Symbol(effect/Runtime/FiberFailure)]: Symbol(effect/Runtime/FiberFailure),
[Symbol(effect/Runtime/FiberFailure/Cause)]: {
_tag: 'Fail',
error: InvalidDate [Error]: toaster is not a valid timestamp
at <anonymous> (/home/fred/dev/learn-effect/main.ts:11:2)
at ModuleJob.run (node:internal/modules/esm/module_job:218:25)
at async ModuleLoader.import (node:internal/modules/esm/loader:329:24)
at async loadESM (node:internal/process/esm_loader:28:7)
at async handleMainPromise (node:internal/modules/run_main:113:12)
}
}
Note that the error thrown by Effect.runSync
is not itself an InvalidDate
, but a wrapper around it. When we look at more techniques for error handling - and especially at Effect’s fiber-based concurrency model - we shall see why this is useful.
import { Effect } from "effect";
class InvalidDate extends Error {}
const reformat = (timestamp: string) => {
const parsed = Date.parse(timestamp);
if (Number.isNaN(parsed)) {
return Effect.fail(
new InvalidDate(`${timestamp} is not a valid timestamp`),
);
}
const date = new Date(parsed);
return Effect.succeed(date.toISOString());
};
const program = reformat("toaster");
try {
Effect.runSync(program);
} catch (e) {
console.error(e);
}
Our program now shows up as
const program: Effect.Effect<never, InvalidDate, never> | Effect.Effect<string, never, never>
In plain English our program is
- either an effect that never holds a value, but may fail with InvalidDate
- or an effect of string
We can help TypeScript out and document the type explicitly
const reformat = (timestamp: string): Effect.Effect<string, InvalidDate> => {
// ...
}
The following types are essentially equivalent.
// These types (A, B, C) are all the same.
// We could refer to this as "an Effect of string, but may fail with InvalidDate".
type A = Effect.Effect<string, InvalidDate, never>
type B = Effect.Effect<string, InvalidDate>
type C = Effect.Effect<string, never> | Effect.Effect<never, InvalidDate>
Note that inside the catch block, we still don’t know anything about the type of e
. This is because Effect.runSync
acts as the boundary between Effect and regular JavaScript. In future lessons we will see how to manipulate errors inside our effects.
Let’s see how Effect.fail
works at a lower level, using a very simple example.
import { Effect } from "effect";
const good = Effect.succeed("good");
const bad = Effect.fail("bad");
console.log(good);
console.log(bad);
We can see that both successes and failures are stored as objects, but that internally the structure is quite different. Again, notice that this script does not fail, because the failing effect is never run.
$ npx tsx main.ts
{ _id: 'Exit', _tag: 'Success', value: 'good' }
{
_id: 'Exit',
_tag: 'Failure',
cause: { _id: 'Cause', _tag: 'Fail', failure: 'bad' }
}
Although you can fail with any type, it is most common to fail with a subclass of Error. JavaScript Error objects have some useful out-of-the-box features, like the stack trace.
Note that there is a key difference between Effect.Effect<A | E>
and Effect.Effect<A, E>
. The former says that when run, the final result may be an A or an E, and that running will never fail. The latter says that when run, the final result may be an A, or the run may fail with an E.
import { Effect } from "effect";
const a = Effect.succeed("a" as const);
const b = Effect.fail("b" as const);
const c = Effect.succeed("c" as const);
const d = Effect.fail("d" as const);
const possible = [a, b, c, d];
const chosenIndex = Math.floor(Math.random() * possible.length);
const program = possible[chosenIndex];
const result = Effect.runSync(program);
console.log(result, "is a or c");
The type system is correctly able to determine that the type of result
(if the script gets that far) is "a" | "c"
. If the program does not run to completion, then result is never assigned and the rest of the script never runs.
In general, we can combine Effect types in the “obvious” way.
// Suppose we have two effects
type A1UnlessE1 = Effect.Effect<A1, E1>
type A2UnlessE2 = Effect.Effect<A2, E2>
// These types are all essentially the same
type UnionOfEffects = A1UnlessE1 | A2UnlessE2
type EffectOfUnions = Effect.Effect<A1 | A2, E1 | E2>
type TotallySplit = Effect.Effect<A1> | Effect.Efffect<A2> | Effect.Effect<never, E1> | Effect.Effect<never, E2>
It’s very common to see Effects that have multiple possible errors.
import { Effect } from "effect";
class InvalidTimestamp extends Error {
_kind = 'InvalidTimestamp'
}
class DateOutOfBounds extends Error {
_kind = 'DateOutOfBounds'
}
const YEAR_3000 = 32503680000000
const reformat = (timestamp: string) => {
const parsed = Date.parse(timestamp);
if (Number.isNaN(parsed)) {
return Effect.fail(
new InvalidTimestamp(`${timestamp} is not a valid timestamp`),
);
}
if (parsed <= 0 || parsed >= YEAR_3000) {
return Effect.fail(
new DateOutOfBounds(`${timestamp} is outside the acceptable range`)
)
}
const date = new Date(parsed);
return Effect.succeed(date.toISOString());
};
const program = reformat("toaster");
try {
Effect.runSync(program);
} catch (e) {
console.error(e);
}
For something more complex, like placeOrder
on an e-commerce platform, you can see how the multiple errors might need handling in different ways:
- redirect the user to retry with new details after a
CardPaymentDeclined
- retry the request automatically after
PaymentGatewayUnavailable
- continue anyway after
ItemTemporarilyOutOfStock
, but hide the expected delivery date on the confirmation screen
It’s worth knowing that Effect makes a distinction between Failures and Defects. Both represent something going wrong. A Failure is an error that your program can and should do something about. A Defect is an error that your program can do nothing about. A Defect is an unrecoverable error.
For example, FormValidationError
and HttpRequestFailed
are things that can go wrong. Your program should almost definitely be aware of these errors and should do something about them. Even showing a generic “Something went wrong” message to the user is better than an endless spinner. MissingEnvironmentVariable
and CorruptedFileSystem
, on the other hand, might not be situations that your program can recover from.
Defects are not tracked by the type system, because this information is not expected to be useful. At runtime, both Failures and Defects will throw an error when the program is run with Effect.runSync
, but there are ways to differentiate between them.
Let’s recap:
- Effect-the-data-type is a data type with up to 3 generic parameters.
- The first parameter represents the “value” that this Effect holds.
- The second parameter represents failures that can occur when this Effect is run.
- Effect.fail creates an Effect that will always fail when run.
- An Effect is a plain JavaScript object behind the scenes, but we will rarely inspect its contents directly.
Effect<A1, E1> | Effect<A2, E2>
can be simplified asEffect<A1 | A2, E1 | E2>
.- Effect-the-ecosystem aims to help developers write “production-ready”, fault-tolerant systems.
- Effect-the-library includes tools to make error-handling simpler to reason about.