diff --git a/lib/util/result.spec.ts b/lib/util/result.spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..74d79451747e3da94338651e6397e6f4dafb5661
--- /dev/null
+++ b/lib/util/result.spec.ts
@@ -0,0 +1,119 @@
+import { Result } from './result';
+
+describe('util/result', () => {
+  describe('ok', () => {
+    it('constructs successful result from value', () => {
+      expect(Result.ok(42).value()).toBe(42);
+    });
+  });
+
+  describe('err', () => {
+    it('constructs error result', () => {
+      const res = Result.err();
+      expect(res.error()).toEqual(new Error());
+    });
+
+    it('constructs error result from string', () => {
+      const res = Result.err('oops');
+      expect(res.error()?.message).toBe('oops');
+    });
+
+    it('constructs error result from Error instance', () => {
+      const err = new Error('oops');
+      const res = Result.err(err);
+      expect(res.error()).toBe(err);
+    });
+  });
+
+  describe('wrap', () => {
+    it('wraps function returning successful result', () => {
+      const res = Result.wrap(() => 42);
+      expect(res.value()).toBe(42);
+    });
+
+    it('wraps function that throws an error', () => {
+      const res = Result.wrap(() => {
+        throw new Error('oops');
+      });
+      expect(res.error()?.message).toBe('oops');
+    });
+
+    it('wraps promise resolving to value', async () => {
+      const res = await Result.wrap(Promise.resolve(42));
+      expect(res.value()).toBe(42);
+    });
+
+    it('wraps promise rejecting with error', async () => {
+      const err = new Error('oops');
+      const res = await Result.wrap(Promise.reject(err));
+      expect(res.error()?.message).toBe('oops');
+    });
+  });
+
+  describe('transform', () => {
+    const fn = (x: string) => x.toUpperCase();
+
+    it('transforms successful result', () => {
+      const res = Result.ok('foo').transform(fn);
+      expect(res).toEqual(Result.ok('FOO'));
+    });
+
+    it('no-op for error result', () => {
+      const err = new Error('bar');
+      const res = Result.err(err).transform(fn);
+      expect(res.value()).toBeUndefined();
+      expect(res.error()).toBe(err);
+    });
+  });
+
+  describe('unwrap', () => {
+    it('unwraps successful result', () => {
+      const res = Result.ok(42);
+      expect(res.unwrap()).toEqual({ ok: true, value: 42 });
+    });
+
+    it('unwraps error result with fallback value', () => {
+      const err = new Error('oops');
+      const res = Result.err(err);
+      expect(res.unwrap(42)).toEqual({ ok: true, value: 42 });
+    });
+
+    it('unwraps error result', () => {
+      const err = new Error('oops');
+      const res = Result.err(err);
+      expect(res.unwrap()).toEqual({ ok: false, error: err });
+    });
+  });
+
+  describe('value', () => {
+    it('returns successful value', () => {
+      const res = Result.ok(42);
+      expect(res.value()).toBe(42);
+    });
+
+    it('returns fallback value for error result', () => {
+      const err = new Error('oops');
+      const res = Result.err(err);
+      expect(res.value(42)).toBe(42);
+    });
+
+    it('returns undefined value for error result', () => {
+      const err = new Error('oops');
+      const res = Result.err(err);
+      expect(res.value()).toBeUndefined();
+    });
+  });
+
+  describe('error', () => {
+    it('returns undefined error for successful result', () => {
+      const res = Result.ok(42);
+      expect(res.error()).toBeUndefined();
+    });
+
+    it('returns error for non-successful result', () => {
+      const err = new Error('oops');
+      const res = Result.err(err);
+      expect(res.error()).toEqual(err);
+    });
+  });
+});
diff --git a/lib/util/result.ts b/lib/util/result.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ae6dcf917a0c7ca86500a3b8504d7ea350fb0bd5
--- /dev/null
+++ b/lib/util/result.ts
@@ -0,0 +1,90 @@
+interface Ok<T> {
+  ok: true;
+  value: T;
+}
+
+interface Err {
+  ok: false;
+  error: Error;
+}
+
+type Res<T> = Ok<T> | Err;
+
+export class Result<T> {
+  static ok<T>(value: T): Result<T> {
+    return new Result({ ok: true, value });
+  }
+
+  static err(): Result<never>;
+  static err(error: Error): Result<never>;
+  static err(message: string): Result<never>;
+  static err(error?: Error | string): Result<never> {
+    if (typeof error === 'undefined') {
+      return new Result({ ok: false, error: new Error() });
+    }
+
+    if (typeof error === 'string') {
+      return new Result({ ok: false, error: new Error(error) });
+    }
+
+    return new Result({ ok: false, error });
+  }
+
+  private static wrapCallback<T>(callback: () => T): Result<T> {
+    try {
+      return Result.ok(callback());
+    } catch (error) {
+      return Result.err(error);
+    }
+  }
+
+  private static wrapPromise<T>(promise: Promise<T>): Promise<Result<T>> {
+    return promise.then(
+      (value) => Result.ok(value),
+      (error) => Result.err(error)
+    );
+  }
+
+  static wrap<T>(callback: () => T): Result<T>;
+  static wrap<T>(promise: Promise<T>): Promise<Result<T>>;
+  static wrap<T>(
+    input: (() => T) | Promise<T>
+  ): Result<T> | Promise<Result<T>> {
+    return input instanceof Promise
+      ? Result.wrapPromise(input)
+      : Result.wrapCallback(input);
+  }
+
+  private constructor(private res: Res<T>) {}
+
+  transform<U>(fn: (value: T) => U): Result<U> {
+    return this.res.ok
+      ? Result.ok(fn(this.res.value))
+      : Result.err(this.res.error);
+  }
+
+  unwrap(): Res<T>;
+  unwrap<U>(fallback: U): Res<T | U>;
+  unwrap<U>(fallback?: U): Res<T | U> {
+    if (this.res.ok) {
+      return this.res;
+    }
+
+    if (arguments.length) {
+      return { ok: true, value: fallback as U };
+    }
+
+    return this.res;
+  }
+
+  value(): T | undefined;
+  value<U>(fallback: U): T | U;
+  value<U>(fallback?: U): T | U | undefined {
+    const res = arguments.length ? this.unwrap(fallback as U) : this.unwrap();
+    return res.ok ? res.value : undefined;
+  }
+
+  error(): Error | undefined {
+    return this.res.ok ? undefined : this.res.error;
+  }
+}