Data.TaggedError

In the previous lesson we saw how Effect.catchTag can be helpful for handling “tagged” errors. Tagging errors is such a common convention that the Effect library ships with a Data.TaggedError helper.

Here’s what the basic example looks like using Data.TaggedError:

import { Data, Effect } from "effect";

class InvalidUrl extends Data.TaggedError("@fred.c/InvalidUrl")<{}> {}

const input = "not-a-url";
const tried = Effect.try(() => new URL(input as any));

Effect.fail(new InvalidUrl()).pipe(
	Effect.catchTag("@fred.c/InvalidUrl", (e) => Effect.succeed(console.error(e))),
	Effect.runSync,
);

Note that Data.TaggedError is not itself a class, but a function that returns a class. Let’s break this down a little further.

const BaseError = Data.TaggedError("@fred.c/InvalidUrl");
// note: example has a weird type, but we can work with it
const example = new BaseError(undefined);

// in particular, it _is_ an Error
Effect.fail(example).pipe(
	Effect.catchTag("@fred.c/InvalidUrl", (e) =>
		Effect.succeed({
			name: e.name,
			stack: e.stack,
			message: e.message,
			cause: e.cause,
			string: e.toString(),
			json: JSON.stringify(e.toJSON()),
		}),
	),
	Effect.map(console.log),
	Effect.runSync,
);

The output looks something like this

$ npx tsx main.ts
{
  name: '@fred.c/InvalidUrl',
  stack: '@fred.c/InvalidUrl\n' +
    '    at <anonymous> (/home/fred/dev/learn-effect/main.ts:4:17)\n' +
    '    at ModuleJob.run (node:internal/modules/esm/module_job:218:25)\n' +
    '    at async ModuleLoader.import (node:internal/modules/esm/loader:329:24)\n' +
    '    at async loadESM (node:internal/process/esm_loader:28:7)\n' +
    '    at async handleMainPromise (node:internal/modules/run_main:113:12)',
  message: '',
  cause: undefined,
  string: '@fred.c/InvalidUrl',
  json: '{"_tag":"@fred.c/InvalidUrl"}'
}

The error has all the usual properties we would expect from an Error, like name, message and cause. It also has a .toJSON() method, which returns a JSON-serializable object. It’s worth noting that Error.cause is a native JavaScript property, which is not the same as Effect’s Cause type, which we will encounter in future lessons.

We can also provide a generic parameter to the constructor, specifying the shape of data that can be passed to the constructor. These properties will be set on the error that gets returned. In the example below, note that we can access .url in a type-safe way.

interface BaseErrorProps {
	url: string;
	message: string;
	cause?: unknown;
}

const BaseError = Data.TaggedError("@fred.c/InvalidUrl");
const example = new BaseError<BaseErrorProps>({
	url: "http://nope",
	message: "http://nope is not a valid URL",
	cause: new Error("could not parse URL"),
});

Effect.fail(example).pipe(
	Effect.catchTag("@fred.c/InvalidUrl", (e) =>
		Effect.succeed({
			name: e.name,
			stack: e.stack,
			message: e.message,
			cause: e.cause,
            url: e.url,
			string: e.toString(),
			json: JSON.stringify(e.toJSON()),
		}),
	),
	Effect.map(console.log),
	Effect.runSync,
);

The output is also more useful, with these extra properties:

$ npx tsx main.ts
{
  name: '@fred.c/InvalidUrl',
  stack: '@fred.c/InvalidUrl: http://nope is not a valid URL\n' +
    '    at <anonymous> (/home/fred/dev/learn-effect/main.ts:10:17)\n' +
    '    at ModuleJob.run (node:internal/modules/esm/module_job:218:25)\n' +
    '    at async ModuleLoader.import (node:internal/modules/esm/loader:329:24)\n' +
    '    at async loadESM (node:internal/process/esm_loader:28:7)\n' +
    '    at async handleMainPromise (node:internal/modules/run_main:113:12)',
  message: 'http://nope is not a valid URL',
  cause: Error: could not parse URL
      at <anonymous> (/home/fred/dev/learn-effect/main.ts:13:9)
      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),
  url: 'http://nope',
  string: '@fred.c/InvalidUrl: http://nope is not a valid URL',
  json: '{"url":"http://nope","message":"http://nope is not a valid URL","cause":{},"_tag":"@fred.c/InvalidUrl"}'
}

By extending this BaseError, we can define a TypeScript type as well as a constructor.

const BaseError = Data.TaggedError("@fred.c/InvalidUrl");

class InvalidUrl extends BaseError<BaseErrorProps> {}

//       ┌─── InvalidUrl
//       ▼
const example = new InvalidUrl({
	url: "http://nope",
	message: "http://nope is not a valid URL",
	cause: new Error("could not parse URL"),
});

This example behaves the same way as before, but with a convenient name for the type. We also ensure that all InvalidUrls will have a url: string property.

We’ve left the class body empty, but we can add extra methods or static members if they would be useful. In particular, I often find it helpful to add factory methods like RequestFailure.forUrl or ResponseFailure.forResponse. Structuring these in a partial application style helps with functions like Effect.tryPromise that expect a callback that accepts the cause as the argument.

class RequestFailure extends Data.TaggedError("@fred.c/RequestFailure")<{
	message: string;
	cause: unknown;
	url: string;
}> {
	static forUrl = (url: string) => (cause: unknown) =>
		new RequestFailure({
			message: `unable to fetch ${url} because ${cause}`,
			url,
			cause,
		});
}

class ResponseFailure extends Data.TaggedError("@fred.c/ResponseFailure")<{
	message: string;
	cause: unknown;
	response: Response;
}> {
	isClientFailure = this.response.status >= 400 && this.response.status < 500;
	isServerFailure = this.response.status >= 500;

	static forResponse = (response: Response) => (cause?: unknown) =>
		new ResponseFailure({
			message: `request failed with status ${response.status} (${response.statusText})`,
			response,
			cause,
		});
}

const _makeRequest = (url: string) =>
	Effect.tryPromise({
		try: () => fetch(url),
		catch: RequestFailure.forUrl(url),
	});

const _checkResponse = (response: Response) => {
	if (!response.ok) {
		return Effect.fail(ResponseFailure.forResponse(response)());
	}
	return Effect.tryPromise({
		try: async (): Promise<unknown> => response.text(),
		catch: ResponseFailure.forResponse(response),
	});
};

const get = (url: string) =>
	_makeRequest(url).pipe(Effect.flatMap(_checkResponse));

get("https://httpbin.org/status/418").pipe(
	Effect.catchTag("@fred.c/ResponseFailure", (e) => {
		if (e.isServerFailure) {
			return Effect.succeed("server is unhappy, try again later");
		}
		return Effect.succeed(`please check the inputs (${e.response.statusText})`);
	}),
	Effect.map(console.log),
	Effect.runPromise,
);

One final thing to note about errors created with Data.TaggedError is that they are “yieldable”. We’ll see why this name is used in future lessons. For now it is enough to note that this can be a convenient shortcut: the error does not need to be wrapped in Effect.fail.

class YouLose extends Data.TaggedError("@fred.c/YouLose")<{}> {}

const flipCoin = Effect.sync(() => (Math.random() > 0.5 ? "HEADS" : "TAILS"));

const checkResult = (
	flip: "HEADS" | "TAILS",
): Effect.Effect<string, YouLose> => {
	if (flip === "HEADS") {
		return Effect.succeed("you win!");
	}
    // does the same thing as
    // return Effect.fail(new YouLose());
    // but with fewer keystrokes
	return new YouLose();
};

flipCoin.pipe(
	Effect.flatMap(checkResult),
	Effect.map(console.log),
	Effect.catchAll((e) => Effect.succeed(console.error(e))),
	Effect.runSync,
);

This works because a tagged error is a valid effect.

const badOutcome: Effect.Effect<never, YouLose> = new YouLose()

That being said, these tagged errors also behave like standard JavaScript error classes. If you are refactoring an existing codebase to use Effect, adding tags to error classes can be a very helpful way to improve type safety. If you are working on a greenfield codebase, I would recommend defining all errors using Data.TaggedError.

I have a code snippet set up to help with the boilerplate, which you may find useful:

snippet error
class $1 extends Data.TaggedError(
	"$2/$1"
)<{
	cause: unknown;
	message: string;
}> {
	static of = (cause: unknown): $1 => new $1({ cause, message: "$3" });
}
endsnippet

Let’s recap:

  • Effect makes it simpler to write declarative error handling logic
  • adding a _tag property to Error types makes error handling simpler in Effect
  • Data.TaggedError is a shortcut for defining tagged errors
  • Data.TaggedError has other benefits, like being convertible to an Effect that always fails