Skip to content

Commit

Permalink
feat: add option support and sandboxing
Browse files Browse the repository at this point in the history
  • Loading branch information
nickfla1 committed Jun 30, 2024
1 parent 7c7cd40 commit ec1da12
Show file tree
Hide file tree
Showing 13 changed files with 642 additions and 83 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@

dist
node_modules
coverage
3 changes: 1 addition & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.organizeImports.biome": "explicit"
"quickfix.biome": "explicit"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
Expand Down
15 changes: 4 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,7 @@
"type": "module",
"version": "0.3.0",
"description": "Type safe result utilities for TypeScript",
"keywords": [
"result",
"types",
"error",
"throw",
"function",
"immutable"
],
"keywords": ["result", "types", "error", "throw", "function", "immutable"],
"license": "MIT",
"repository": {
"type": "git",
Expand All @@ -25,16 +18,16 @@
},
"scripts": {
"test": "vitest run",
"coverage": "vitest run --coverage",
"build": "tsc"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"/dist"
],
"files": ["/dist"],
"packageManager": "[email protected]",
"devDependencies": {
"@biomejs/biome": "1.8.3",
"@vitest/coverage-v8": "^1.6.0",
"typescript": "^5.5.2",
"vitest": "^1.6.0"
}
Expand Down
9 changes: 9 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class UnwrapError<E> extends Error {
readonly originalError: E;

constructor(error: E) {
super("unwrap error");

this.originalError = error;
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/* v8 ignore next 2 */
export * from "./result.js";
export * from "./option.js";
37 changes: 37 additions & 0 deletions src/option.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test } from "vitest";
import { kOptionKind } from "./symbols.js";
import { isNone, isOption, isSome, none, some } from "./option.js";

test("creates an immutable option", () => {
const res = some("hello");

expect(res).toEqual({ data: "hello", [kOptionKind]: "some" });

expect(() => {
/* @ts-expect-error */
res.data = "goodbye";
}).toThrowError(/^Cannot assign to read only property 'data'*/i);
});

test("creates an immutable none option", () => {
const res = none();

expect(res).toEqual({ [kOptionKind]: "none" });
});

test("isSome returns true if the option contains data", () => {
expect(isSome(some("hello"))).toBeTruthy();
expect(isSome(none())).toBeFalsy();
});

test("isNone returns true if the option does not contain data", () => {
expect(isNone(none())).toBeTruthy();
expect(isNone(some("hello"))).toBeFalsy();
});

test("isOption returns true if the input is an option", () => {
expect(isOption(some("hello"))).toBeTruthy();
expect(isOption(none())).toBeTruthy();
expect(isOption(123)).toBeFalsy();
expect(isOption({})).toBeFalsy();
});
43 changes: 43 additions & 0 deletions src/option.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { kOptionKind } from "./symbols.js";

const KIND_SOME = "some" as const;
const KIND_NONE = "none" as const;

export type Some<T> = {
readonly data: T;
readonly [kOptionKind]: typeof KIND_SOME;
};

export type None = {
readonly [kOptionKind]: typeof KIND_NONE;
};

export type Option<T> = Some<T> | None;

const NONE: None = Object.freeze({ [kOptionKind]: KIND_NONE });

export function some<T>(data: T): Some<T> {
return Object.freeze({ data, [kOptionKind]: KIND_SOME });
}

export function none(): None {
return NONE;
}

export function isSome<T>(maybeSome: Option<T>): maybeSome is Some<T> {
return maybeSome[kOptionKind] === KIND_SOME;
}

export function isNone<T>(maybeNone: Option<T>): maybeNone is None {
return maybeNone[kOptionKind] === KIND_NONE;
}

export function isOption<T = unknown>(
maybeOption: unknown
): maybeOption is Option<T> {
return (
!!maybeOption &&
typeof maybeOption === "object" &&
kOptionKind in maybeOption
);
}
38 changes: 11 additions & 27 deletions src/result.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { expect, test } from "vitest";
import { err, isErr, isOk, map, mapErr, ok, unwrap, unwrapOr } from "./result";
import { err, isErr, isOk, isResult, ok } from "./result.js";
import { kResultKind } from "./symbols.js";

test("creates an immutable ok result", () => {
const res = ok("hello");

expect(res).toEqual({ ok: "hello" });
expect(res).toEqual({ data: "hello", [kResultKind]: "ok" });

expect(() => {
/* @ts-expect-error */
res.ok = "goodbye";
}).toThrowError(/^Cannot assign to read only property 'ok'*/i);
res.data = "goodbye";
}).toThrowError(/^Cannot assign to read only property 'data'*/i);
});

test("creates an immutable error result", () => {
const res = err("badcode");

expect(res).toEqual({ err: "badcode" });
expect(res).toEqual({ err: "badcode", [kResultKind]: "err" });

expect(() => {
/* @ts-expect-error */
Expand All @@ -33,26 +34,9 @@ test("isErr returns true if result is an error", () => {
expect(isErr(ok("hello"))).toBeFalsy();
});

test("should unwrap a success result", () => {
expect(unwrap(ok("hello"))).toStrictEqual("hello");
});

test("should throw if we try to unwrap an error", () => {
expect(() => {
unwrap(err("hello"));
}).toThrowError(/failed to unwrap/);
});

test("should return a fallback if an error is unwrapped", () => {
expect(unwrapOr(err("badcode"), "hello")).toStrictEqual("hello");
});

test("it should map a result", () => {
expect(map(ok("hello"), (str) => str.toUpperCase())).toStrictEqual("HELLO");
});

test("it should map an error result", () => {
expect(
mapErr(err("badcode"), (str) => str.replace("bad", "good"))
).toStrictEqual("goodcode");
test("isResult returns true if the input is a result", () => {
expect(isResult(ok("hello"))).toBeTruthy();
expect(isResult(err("nope"))).toBeTruthy();
expect(isResult(123)).toBeFalsy();
expect(isResult({})).toBeFalsy();
});
73 changes: 31 additions & 42 deletions src/result.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,35 @@
export type OkResult<T> = { readonly ok: T };
export type ErrResult<E> = { readonly err: E };
export type Result<T, E> = OkResult<T> | ErrResult<E>;

export const ok = <T>(data: T): OkResult<T> => Object.freeze({ ok: data });
export const err = <E>(error: E): ErrResult<E> => Object.freeze({ err: error });

export const isOk = <T, E>(maybeOk: Result<T, E>): maybeOk is OkResult<T> =>
"ok" in maybeOk;
export const isErr = <T, E>(maybeErr: Result<T, E>): maybeErr is ErrResult<E> =>
"err" in maybeErr;

export const unwrap = <T, E>(res: Result<T, E>): T | never => {
if (isErr(res)) {
throw new TypeError("failed to unwrap result");
}
import { kResultKind } from "./symbols.js";

return res.ok;
};
const KIND_OK = "ok";
const KIND_ERR = "err";

export const unwrapOr = <T, E, O extends T>(
res: Result<T, E>,
fallback: O
): T | O => {
if (isErr(res)) {
return fallback;
}
export type OkResult<T> = { readonly data: T; [kResultKind]: typeof KIND_OK };
export type ErrResult<E> = { readonly err: E; [kResultKind]: typeof KIND_ERR };

return res.ok;
};

export type MapFn<T, R> = (item: T) => R;

export const map = <T, E, R>(res: Result<T, E>, fn: MapFn<T, R>): R | never =>
fn(unwrap(res));

export const mapErr = <T, E, R>(
res: Result<T, E>,
fn: MapFn<E, R>
): R | never => {
if (isOk(res)) {
throw new TypeError("cannot error map an ok result");
}
export type Result<T, E> = OkResult<T> | ErrResult<E>;

return fn(res.err);
};
export function ok<T>(data: T): OkResult<T> {
return Object.freeze({ data, [kResultKind]: KIND_OK });
}

export function err<E>(err: E): ErrResult<E> {
return Object.freeze({ err, [kResultKind]: KIND_ERR });
}

export function isOk<T, E>(maybeOk: Result<T, E>): maybeOk is OkResult<T> {
return maybeOk[kResultKind] === KIND_OK;
}

export function isErr<T, E>(maybeErr: Result<T, E>): maybeErr is ErrResult<E> {
return maybeErr[kResultKind] === KIND_ERR;
}

export function isResult<T = unknown, E = unknown>(
maybeResult: unknown
): maybeResult is Result<T, E> {
return (
!!maybeResult &&
typeof maybeResult === "object" &&
kResultKind in maybeResult
);
}
2 changes: 2 additions & 0 deletions src/symbols.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const kResultKind = Symbol.for("resultKind");
export const kOptionKind = Symbol.for("optionKind");
94 changes: 94 additions & 0 deletions src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { expect, test } from "vitest";
import { map, mapErr, run, unwrap, unwrapOr } from "./utils.js";
import { err, isErr, isOk, ok, type Result } from "./result.js";
import { UnwrapError } from "./error.js";
import { none, type Option, some } from "./option.js";

test("should unwrap a success result", () => {
expect(unwrap(ok("hello"))).toStrictEqual("hello");
});

test("should throw if we try to unwrap an error", () => {
expect(() => {
unwrap(err("hello"));
}).toThrowError(UnwrapError);
});

test("should return a fallback if an error is unwrapped", () => {
expect(unwrapOr(err("badcode"), "hello")).toStrictEqual("hello");
});

test("should return a fallback if none is unwrapped", () => {
expect(unwrapOr(none(), "hello")).toStrictEqual("hello");
});

test("unwrapOr should return a original data", () => {
expect(unwrapOr(some("hi"), "hello")).toStrictEqual("hi");
});

test("it should map a result", () => {
expect(map(ok("hello"), (str) => str.toUpperCase())).toEqual(ok("HELLO"));
});

test("mapError should map an error result", () => {
expect(mapErr(err("badcode"), (str) => str.replace("bad", "good"))).toEqual(
err("goodcode")
);
});

test("mapError should return the data of an ok result", () => {
expect(
mapErr(ok("badcode") as Result<string, string>, (str) =>
str.replace("bad", "good")
)
).toEqual(ok("badcode"));
});

test("it should map an option", () => {
expect(map(some("hello"), (str) => str.toUpperCase())).toEqual(some("HELLO"));
});

test("it should map an none option", () => {
expect(map(none() as Option<string>, (str) => str.toUpperCase())).toEqual(
none()
);
});

test("run should sandbox an execution", () => {
const res = run(() => {
return ok("hello");
});

expect(isOk(res)).toBeTruthy();
expect(unwrap(res)).toStrictEqual("hello");
});

test("run should sandbox an async execution", async () => {
const res = await run(async () => {
return ok("hello");
});

expect(isOk(res)).toBeTruthy();
expect(unwrap(res)).toStrictEqual("hello");
});

test("run should return an error result if an unwrap error happens inside", () => {
const res = run(() => {
unwrap(err("fail"));

return ok("hello");
});

expect(isErr(res)).toBeTruthy();
});

test("run throws with non-unwrap errors", () => {
expect(() => {
run(() => {
throw new Error("test");

// biome-ignore lint: test purposes
return ok("hello");
});
}).toThrow();
});
Loading

0 comments on commit ec1da12

Please sign in to comment.