Type-safe error management using generators. Inspired by [EffectTS](https://effect.website/) and [typescript-result](https://github.com/everweij/typescript-result)
npm install ts-flowgenType-safe error management using generators. Inspired by EffectTS and typescript-result
- Usage
- Without flowgen
- With Result pattern
- With flowgen
- API
- flow(generator)
- gen(callback)
- never()
- noop()
- identity()
- all()
- race()
- timeout()
- unsafeFlowOrThrow()
You throw your errors and thus relies on untyped goto-like pattern:
``tsInvalid denominator: ${b}
async function dependency1(a: number, b: number): number {
if (b === 0) {
throw new Error();
}
return a / b;
}
async function dependency2(a: number, b: number): number {
if (b === 0) {
throw new Error(Invalid denominator: ${b});
}
return a / b;
}
async function main(userInput: number) {
try {
const result = await dependency1(10, userInput);
const result2 = await dependency2(20, userInput);
console.log(result + result2);
} catch (error / error is unknown /) {
console.log("Some error happened", error);
}
}
`
You have verbose but type-safe code. For every result you have to check if the result is an error:
`ts
type Result
| { ok: true; value: Value }
| { ok: false; error: Error };
async function dependency1(
a: number,
b: number
): Promise
if (b === 0) {
return {
ok: false,
error: { name: "valueError", message: Invalid denominator: ${b} },
};
}
return { ok: true, value: a / b };
}
async function dependency2(
a: number,
b: number
): Promise
if (b === 0) {
return {
ok: false,
error: { name: "valueError", message: Invalid denominator: ${b} },
};
}
return { ok: true, value: a / b };
}
async function main(userInput: number) {
const result1 = await dependency1(10, userInput);
// for every method you call, you need to infer Result to a successful state
if (result1.ok == false) {
console.log(
"Some error happened",
result1.error / error is properly typed /
);
return;
}
const result2 = await dependency2(20, userInput);
// this means lots of boilerplate code to handle errors
if (result2.ok == false) {
console.log(
"Some error happened",
result2.error / error is properly typed /
);
return;
}
console.log(result1.value + result2.value);
}
`
You get automatic typing for errors and returns:
`ts
async function* dependency1(a: number, b: number) {
if (b === 0) {
yield { type: "valueError", message: "Invalid denominator: 0" } as const;
}
return a / b;
}
async function* dependency2(a: number, b: number) {
if (b === 0) {
yield { type: "valueError", message: "Invalid denominator: 0" } as const;
}
return a / b;
}
async function main(userInput: number) {
const result = await flow(async function* () {
// yield intermediate method which unwraps the value, no chains of if error early return
const value1 = yield* dependency1(10, userInput); // value is number
const value2 = yield* dependency2(10, userInput); // value is number
return value1 + value2;
});
// only one error management per flow with an exhaustive switch
if (result.ok === false) {
/ result.error is properly typed /
switch (result.error.type) {
case "valueError":
console.log("Some error happened", result.error);
return;
default:
never();
}
}
console.log(result.value);
}
`
The only drawbacks are:
1. You have to wrap external libraries if you want to add support for AsyncGenerators
2. You need to yield errors using as const (or type the return of generators) since TypeScript will infer a poorly intersection type instead (instead of a union)
You can find an example of how to use flowgen in src/__tests__/complete-example.test.ts
`ts
async function flow
generator: () => Generator
): Promise<{ ok: true; value: Value } | { ok: false; error: Error }>;
// or the wrapper one:
function wrapFlow
generator: (
...args: Parameters
) => Generator
): (
...args: Parameters
) => Promise<{ ok: true; value: Value } | { ok: false; error: Error }>;
`
This method turns a generator into a promise. Useful as entrypoint before using generators.
Inside the generator, always yield* other generators.
Example:
`ts
const result = await flow(async function* () {
const a = yield* serviceA.methodA();
const b = yield* serviceB.methodB(a);
const c = yield* serviceC.methodC(b);
return c;
});
if (result.ok === false) {
// deal with result.error which is an union of errors yielded by methodA, methodB or methodC
}
// deal with result.value which is equal to c`
Example with wrapFlow:
`ts
async function* someGenerator(value: number) {
const a = yield* serviceA.methodA(value);
const b = yield* serviceB.methodB(a);
const c = yield* serviceC.methodC(b);
return c;
}
const result = await wrapFlow(someGenerator)(42);
if (result.ok === false) {
// deal with result.error which is an union of errors yielded by methodA, methodB or methodC
}
// deal with result.value which is equal to c`
`ts`
function gen
callback: (...args: Parameters) => Value | Promise
unhandledError: (error: unknown) => Error = (error) => error as Error
): (...args: Parameters) => AsyncGenerator
This method turns a sync/async method into a generator.
Example:
`ts
import fs from "node:fs/promises";
const readFile = gen(
async (path: string) => {
return await fs.readFile(path, "utf-8");
},
() => ({ name: "ioError", message: "File not found" })
);
const result = flow(async function* () {
const file = yield* readFile("file.txt");
return file;
});
`
`ts`
function* errdefer
callback: (error: Error) => void | Promise
): Generator
This method is similar to errdefer in other languages (eg. zig). It allows to cleanup eventual leftovers when a method partially failed. Similar to a finally keyword.
It takes the error as parameter if you need it
Example:
`ts
let globalTimeout1: NodeJS.Timeout;
let globalTimeout2: NodeJS.Timeout;
// Two dependency starting a long-living process like a timeout or a database connection
const genLongLivingDependency1 = gen(async function longLivingDependency() {
globalTimeout1 = setTimeout(() => {}, 1000);
return "done";
});
const genLongLivingDependency2 = gen(async function longLivingDependency() {
globalTimeout2 = setTimeout(() => {}, 1000);
return "done";
});
const genFailingDependency = gen(async function failingDependency() {
throw new Error("some failing dependency");
});
async function* main() {
const dependency1 = yield* genLongLivingDependency1();
// this will be called if main has a failure somewhere
yield* errdefer(() => clearTimeout(globalTimeout1));
const dependency2 = yield* genLongLivingDependency2();
// this will be called if main has a failure somewhere, after the first errdefer
yield* errdefer(() => clearTimeout(globalTimeout2));
// since this is failing, it will call every errdefer callback, evaluated in reverse order
const failingDependency = yield* genFailingDependency();
return [dependency1, dependency2, failingDependency];
}
`
`ts`
function never(): never;
A never helper. Can be useful when you want to infer a value after yielding an error.
Example:
`tsInvalid denominator: ${a}
async function* method(a: 1 | 2): AsyncGenerator
if (a === 1) {
yield new Error();
never();
}
return a; // inferred to 2
}
`
`ts`
function noop(): AsyncGenerator
A noop helper. Can be useful when you want to yield nothing just to please the linter to get a generator even if you don't really yield.
Example:
`ts
async function* method() {
yield* noop();
return 42;
}
`
`ts`
function identity
A noop helper. Can be useful when you want to yield a value
Example:
`ts`
async function* method() {
return yield* identity(42);
}
Similar to Promise.all() for generators
`ts`
function all
generators: AsyncGenerator
): AsyncGenerator
Example:
`ts
async function* dep1() {
await setTimeout(50);
return 1;
}
async function* dep2() {
await setTimeout(80);
return 1;
}
const result = flow(async function* () {
// runs in parallel
const [a, b] = yield* all([dep1(), dep2()]);
});
`
Similar to Promise.race() for generators
`ts`
function race
generators: AsyncGenerator
): AsyncGenerator
Example:
`ts
async function* dep1() {
await setTimeout(50);
return 1;
}
async function* dep2() {
await setTimeout(80);
return 2;
}
const result = flow(async function* () {
// runs in parallel
const a = yield* race([dep1(), dep2()]);
// a = 1 since dep1 is faster than dep2
});
`
Helper to make a generator not exceed a specific time
`ts`
function timeout
timeoutInMs: number,
generator: AsyncGenerator
): AsyncGenerator
Example:
`ts
async function* dep1() {
await setTimeout(50);
return 1;
}
const result = flow(async function* () {
const a = yield* timeout(100, dep1());
// a = 1 since dep1 is fast enough
const b = yield* timeout(10, dep1());
// this will timeout and result will be a TimeoutError
});
`
Similar to flow but returns the value or throws instead of returning a result. This will mute error type-safety, use it with caution
`ts`
unsafeFlowOrThrow
callback: () => Generator
): Promise
Example:
`ts
async function* method() {
// this is 100% safe
const a = yield* identity(1);
const b = yield* identity(2);
return a + b;
}
const value = await unsafeFlowOrThrow(method); // value = 3
``