Skip to content

Commit 33c807b

Browse files
committed
✨ feat: Add .catch[All]AndThrow()
Add `.catchAndThrow(error, message?)` and `.catchAllAndThrow(message?)` to allow raising exceptions instead of handling them.
1 parent 3ec84c3 commit 33c807b

6 files changed

Lines changed: 684 additions & 129 deletions

File tree

README.md

Lines changed: 86 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -615,47 +615,6 @@ const createUser: (
615615
>;
616616
```
617617

618-
### Interlude: Where’s “try-catch”?
619-
620-
With side effects, including errors, now handled in a unified way, you may wonder where `try-catch` fits in. The answer is simple: it’s no longer needed. Errors are just effects, so you can handle specific ones with `.catch()` and let others bubble up to higher-level handlers.
621-
622-
For example, suppose your program accepts a JSON string to define settings. You use `JSON.parse` to parse it, but if the JSON is invalid, instead of throwing an error, you want to print a warning and fall back on a default setting. Here’s how to do it:
623-
624-
```typescript
625-
type SyntaxError = Effect.Error<"syntax">;
626-
const syntaxError: EffectFactory<SyntaxError> = error("syntax");
627-
// For other unexpected errors, we use an unresumable effect "raise" to terminate the program
628-
type Raise = Unresumable<Effect<"raise", [error: unknown], never>>;
629-
const raise: EffectFactory<Raise> = effect("raise", { resumable: false });
630-
631-
const parseJSON = <T>(json: string): Effected<SyntaxError | Raise, T> =>
632-
effected(function* () {
633-
try {
634-
return JSON.parse(json);
635-
} catch (e) {
636-
if (e instanceof SyntaxError) return yield* syntaxError(e.message);
637-
return yield* raise(e);
638-
}
639-
});
640-
641-
interface Settings {
642-
/* ... */
643-
}
644-
645-
const defaultSettings: Settings = {
646-
/* ... */
647-
};
648-
649-
const readSettings = (json: string) =>
650-
effected(function* () {
651-
const settings = yield* parseJSON<Settings>(json).catch("syntax", (message) => {
652-
console.error(`Invalid JSON: ${message}`);
653-
return defaultSettings;
654-
});
655-
/* ... */
656-
});
657-
```
658-
659618
### A deep dive into `resume` and `terminate`
660619

661620
Let’s take a closer look at the `resume` and `terminate` functions. `resume` resumes the program with a given value, while `terminate` stops the program with a value immediately. Use `terminate` when you need to end the program early, such as when an error occurs, while `resume` is typically used to continue normal execution.
@@ -956,6 +915,92 @@ const range3 = (start: number, stop: number) =>
956915

957916
The `.catchAll()` method takes a function that receives the error effect name and message, and returns a new value. `range3` behaves the same as `range2`, with an identical type signature.
958917

918+
### Handling error effects
919+
920+
With side effects, including errors, now handled in a unified way, you may wonder where `try-catch` fits in. The answer is simple: it’s no longer needed. Errors are just effects, so you can handle specific ones with `.catch()` and let others bubble up to higher-level handlers.
921+
922+
For example, suppose your program accepts a JSON string to define settings. You use `JSON.parse` to parse it, but if the JSON is invalid, instead of throwing an error, you want to print a warning and fall back on a default setting. Here’s how to do it:
923+
924+
```typescript
925+
type SyntaxError = Effect.Error<"syntax">;
926+
const syntaxError: EffectFactory<SyntaxError> = error("syntax");
927+
// For other unexpected errors, we use an unresumable effect "raise" to terminate the program
928+
type Raise = Unresumable<Effect<"raise", [error: unknown], never>>;
929+
const raise: EffectFactory<Raise> = effect("raise", { resumable: false });
930+
931+
const parseJSON = <T>(json: string): Effected<SyntaxError | Raise, T> =>
932+
effected(function* () {
933+
try {
934+
return JSON.parse(json);
935+
} catch (e) {
936+
if (e instanceof SyntaxError) return yield* syntaxError(e.message);
937+
return yield* raise(e);
938+
}
939+
});
940+
941+
interface Settings {
942+
/* ... */
943+
}
944+
945+
const defaultSettings: Settings = {
946+
/* ... */
947+
};
948+
949+
const readSettings = (json: string) =>
950+
effected(function* () {
951+
const settings = yield* parseJSON<Settings>(json).catch("syntax", (message) => {
952+
console.error(`Invalid JSON: ${message}`);
953+
return defaultSettings;
954+
});
955+
/* ... */
956+
});
957+
```
958+
959+
As shown in the previous section, you can also use `.catchAll()` to catch all error effects at once, which is useful if you want a unified response to all errors. For instance:
960+
961+
```typescript
962+
const tolerantRange = (start: number, stop: number): Effected<Log, number[]> =>
963+
range(start, stop).catchAll((error, message) => {
964+
console.warn(`Error(${error}): ${message || ""}`);
965+
return [];
966+
});
967+
```
968+
969+
Running `tolerantRange(4, 1).resume("log", console.log).runSync()` will output `[]`, with a warning message printed to the console:
970+
971+
```text
972+
Error(range): Start must be less than stop
973+
```
974+
975+
If you prefer some errors to raise exceptions instead of handling them within your effects system, you can use the `.catchAndThrow(error, message?)` method:
976+
977+
```typescript
978+
// Throws "type" error effect as an exception with its original message
979+
const range2 = (start: number, stop: number) => range(start, stop).catchAndThrow("type");
980+
981+
// Throws "type" error effect with a custom message
982+
const range3 = (start: number, stop: number) =>
983+
range(start, stop).catchAndThrow("type", "Invalid start or stop value");
984+
985+
// Throws "range" error effect with a customized message based on the error
986+
const range4 = (start: number, stop: number) =>
987+
range(start, stop).catchAndThrow("range", (message) => `Invalid range: ${message}`);
988+
```
989+
990+
For example, running `range2(1.5, 2).catch("range", () => {}).resume("log", console.log).runSync()` will throw an exception with the message “Start and stop must be integers”.
991+
992+
To throw all error effects as exceptions, you can use `.catchAllAndThrow(message?)`:
993+
994+
```typescript
995+
const range2 = (start: number, stop: number) => range(start, stop).catchAllAndThrow();
996+
997+
const range3 = (start: number, stop: number) =>
998+
range(start, stop).catchAllAndThrow("An error occurred while generating the range");
999+
1000+
const range4 = (start: number, stop: number) =>
1001+
range(start, stop).catchAllAndThrow((error, message) => `Error(${error}): ${message}`);
1002+
```
1003+
9591004
### Abstracting/combining handlers
9601005

9611006
Not all effects are totally independent from each other; sometimes, you may want to “group” effects that are closely related. A pair of getters and setters for global state is a good example (from the [Koka documentation](https://koka-lang.github.io/koka/doc/book.html#sec-return)):

src/README.example.proof.ts

Lines changed: 94 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -266,43 +266,6 @@ test("The `Effect` type > Provide more readable type information", () => {
266266
);
267267
});
268268

269-
test("Interlude: Where’s “try-catch”?", () => {
270-
type SyntaxError = Effect.Error<"syntax">;
271-
const syntaxError: EffectFactory<SyntaxError> = error("syntax");
272-
type Raise = Unresumable<Effect<"raise", [error: unknown], never>>;
273-
const raise: EffectFactory<Raise> = effect("raise", { resumable: false });
274-
275-
const parseJSON = <T>(json: string): Effected<SyntaxError | Raise, T> =>
276-
effected(function* () {
277-
try {
278-
return JSON.parse(json);
279-
} catch (e) {
280-
if (e instanceof SyntaxError) return yield* syntaxError(e.message);
281-
return yield* raise(e);
282-
}
283-
});
284-
285-
interface Settings {
286-
something: string;
287-
}
288-
289-
const defaultSettings: Settings = {
290-
something: "foo",
291-
};
292-
293-
const readSettings = (json: string) =>
294-
effected(function* () {
295-
const settings = yield* parseJSON<Settings>(json).catch("syntax", (message) => {
296-
console.error(`Invalid JSON: ${message}`);
297-
return defaultSettings;
298-
});
299-
expect(settings).to(equal<Settings>);
300-
/* ... */
301-
});
302-
303-
expect(readSettings).to(equal<(json: string) => Effected<Raise, void>>);
304-
});
305-
306269
test("A deep dive into `resume` and `terminate`", () => {
307270
type Iterate<T> = Effect<"iterate", [value: T], void>;
308271
const iterate = <T>(value: T) => effect("iterate")<[value: T], void>(value);
@@ -506,6 +469,100 @@ test("Handling multiple effects in one handler", () => {
506469
}
507470
});
508471

472+
test("Handling error effects", () => {
473+
{
474+
type SyntaxError = Effect.Error<"syntax">;
475+
const syntaxError: EffectFactory<SyntaxError> = error("syntax");
476+
type Raise = Unresumable<Effect<"raise", [error: unknown], never>>;
477+
const raise: EffectFactory<Raise> = effect("raise", { resumable: false });
478+
479+
const parseJSON = <T>(json: string): Effected<SyntaxError | Raise, T> =>
480+
effected(function* () {
481+
try {
482+
return JSON.parse(json);
483+
} catch (e) {
484+
if (e instanceof SyntaxError) return yield* syntaxError(e.message);
485+
return yield* raise(e);
486+
}
487+
});
488+
489+
interface Settings {
490+
something: string;
491+
}
492+
493+
const defaultSettings: Settings = {
494+
something: "foo",
495+
};
496+
497+
const readSettings = (json: string) =>
498+
effected(function* () {
499+
const settings = yield* parseJSON<Settings>(json).catch("syntax", (message) => {
500+
console.error(`Invalid JSON: ${message}`);
501+
return defaultSettings;
502+
});
503+
expect(settings).to(equal<Settings>);
504+
/* ... */
505+
});
506+
507+
expect(readSettings).to(equal<(json: string) => Effected<Raise, void>>);
508+
}
509+
510+
{
511+
type TypeError = Effect.Error<"type">;
512+
const typeError: EffectFactory<TypeError> = error("type");
513+
type RangeError = Effect.Error<"range">;
514+
const rangeError: EffectFactory<RangeError> = error("range");
515+
516+
type Log = Effect<"log", unknown[], void>;
517+
const log: EffectFactory<Log> = effect("log");
518+
519+
const range = (start: number, stop: number): Effected<TypeError | RangeError | Log, number[]> =>
520+
effected(function* () {
521+
if (start >= stop) return yield* rangeError("Start must be less than stop");
522+
if (!Number.isInteger(start) || !Number.isInteger(stop))
523+
return yield* typeError("Start and stop must be integers");
524+
yield* log(`Generating range from ${start} to ${stop}`);
525+
return Array.from({ length: stop - start }, (_, i) => start + i);
526+
});
527+
528+
const tolerantRange = (start: number, stop: number) =>
529+
range(start, stop).catchAll((error, message) => {
530+
console.warn(`Error(${error}): ${message || ""}`);
531+
return [] as number[];
532+
});
533+
534+
expect(tolerantRange).to(equal<(start: number, stop: number) => Effected<Log, number[]>>);
535+
536+
const range2 = (start: number, stop: number) => range(start, stop).catchAndThrow("type");
537+
538+
expect(range2).to(equal<(start: number, stop: number) => Effected<RangeError | Log, number[]>>);
539+
540+
const range3 = (start: number, stop: number) =>
541+
range(start, stop).catchAndThrow("type", "Invalid start or stop value");
542+
543+
expect(range3).to(equal<(start: number, stop: number) => Effected<RangeError | Log, number[]>>);
544+
545+
const range4 = (start: number, stop: number) =>
546+
range(start, stop).catchAndThrow("range", (message) => `Invalid range: ${message}`);
547+
548+
expect(range4).to(equal<(start: number, stop: number) => Effected<TypeError | Log, number[]>>);
549+
550+
const range5 = (start: number, stop: number) => range(start, stop).catchAllAndThrow();
551+
552+
expect(range5).to(equal<(start: number, stop: number) => Effected<Log, number[]>>);
553+
554+
const range6 = (start: number, stop: number) =>
555+
range(start, stop).catchAllAndThrow("An error occurred while generating the range");
556+
557+
expect(range6).to(equal<(start: number, stop: number) => Effected<Log, number[]>>);
558+
559+
const range7 = (start: number, stop: number) =>
560+
range(start, stop).catchAllAndThrow((error, message) => `Error(${error}): ${message}`);
561+
562+
expect(range7).to(equal<(start: number, stop: number) => Effected<Log, number[]>>);
563+
}
564+
});
565+
509566
test("Abstracting handlers", () => {
510567
{
511568
type State<T> = Effect<"state.get", [], T> | Effect<"state.set", [value: T], void>;

0 commit comments

Comments
 (0)