Add utilities for working with nullable types (#505)
Summary: This commit adds a module with four functions: `get`, `orThrow`, `map`, and `orElse`. Here is a common pattern wherein `get` is useful: ```js sortBy(Array.from(map.keys()), (x) => { const result = map.get(x); if (result == null) { throw new Error("Cannot happen"); } return result.score; }); // versus sortBy(Array.from(map.keys()), (x) => NullUtil.get(map.get(x)).score) ``` (The variant `orThrow` allows specifying a custom message that is only computed in the case where the error will be thrown.) Here is a common pattern where `map` is useful: ```js arr.map((x) => { const result = complicatedComputation(x); return result == null ? result : processResult(result); }); // versus arr.map((x) => NullUtil.map(complicatedComputation(x), processResult)) ``` In each of these cases, by using these functions we gain a dose of safety in addition to our concision: it is tempting to “shorten” the expression `x == null ? y : z` to simply `x ? y : z`, while forgetting that the latter behaves incorrectly for `0`, `false`, `""`, and `NaN`. Similar patterns like `x || defaultValue` also suffer from this problem, and can now be replaced with `orElse`. Designed with @decentralion. Test Plan: Unit tests included; run `yarn travis`. wchargin-branch: null-util
This commit is contained in:
parent
fed58aee7b
commit
4af8ff2471
|
@ -0,0 +1,81 @@
|
|||
// @flow
|
||||
|
||||
/**
|
||||
* Utilities for working with nullable types: `?T = T | null | void`.
|
||||
*
|
||||
* These functions use the native runtime representation, as opposed to
|
||||
* creating an `Optional<T>` wrapper class. This ensures that they have
|
||||
* minimal runtime cost (just a function call), and that they are
|
||||
* trivially interoperable with other code.
|
||||
*
|
||||
* When a value of type `?T` is `null` or `undefined`, we say that it is
|
||||
* _absent_. Otherwise, it is _present_.
|
||||
*
|
||||
* Some functions that typically appear in such libraries are not
|
||||
* needed:
|
||||
*
|
||||
* - `join` (`??T => ?T`) can be implemented as the identity function,
|
||||
* because the Flow types `??T` and `?T` are equivalent;
|
||||
* - `flatMap` (`?T => (T => ?U) => ?U`) can be implemented simply as
|
||||
* `map`, again because `??T` and `?T` are equivalent;
|
||||
* - `first` (`?T => ?T => ?T`) can be implemented simply as `orElse`,
|
||||
* again because `??T` and `?T` are equivalent;
|
||||
* - `isPresent` (`?T => boolean`) doesn't provide much value over the
|
||||
* equivalent abstract disequality check;
|
||||
* - constructors like `empty` (`() => ?T`) and `of` (`T => ?T`) are
|
||||
* entirely spurious.
|
||||
*
|
||||
* Other functions could reasonably be implemented, but have been left
|
||||
* out because they have rarely been needed:
|
||||
*
|
||||
* - `filter` (`?T => (T => boolean) => ?T`);
|
||||
* - `forEach` (`?T => (T => void) => void`);
|
||||
* - `orElseGet` (`?T => (() => T) => T`), which is useful in the case
|
||||
* where constructing the default value is expensive.
|
||||
*
|
||||
* (Of these three, `orElseGet` would probably be the most useful for
|
||||
* our existing codebase.)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Apply the given function inside the nullable. If the input is absent,
|
||||
* then it will be returned unchanged. Otherwise, the given function
|
||||
* will be applied.
|
||||
*/
|
||||
export function map<T, U>(x: ?T, f: (T) => U): ?U {
|
||||
return x != null ? f(x) : x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the value from a nullable. If the input is present, it will
|
||||
* be returned. Otherwise, an error will be thrown with the provided
|
||||
* message (defaulting to the string representation of the absent input).
|
||||
*/
|
||||
export function get<T>(x: ?T, errorMessage?: string): T {
|
||||
if (x == null) {
|
||||
throw new Error(errorMessage != null ? errorMessage : String(x));
|
||||
} else {
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the value from a nullable. If the input is present, it will
|
||||
* be returned. Otherwise, an error will be thrown, with message given
|
||||
* by the provided function.
|
||||
*/
|
||||
export function orThrow<T>(x: ?T, getErrorMessage: () => string): T {
|
||||
if (x == null) {
|
||||
throw new Error(getErrorMessage());
|
||||
} else {
|
||||
return x;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the value from a nullable, using the provided default value
|
||||
* in case the input is absent.
|
||||
*/
|
||||
export function orElse<T>(x: ?T, defaultValue: T): T {
|
||||
return x != null ? x : defaultValue;
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
// @flow
|
||||
|
||||
import * as NullUtil from "./null";
|
||||
|
||||
describe("util/null", () => {
|
||||
describe("map", () => {
|
||||
function sevens(n: ?number): ?string {
|
||||
return NullUtil.map(n, (n: number) => "7".repeat(n) + "!");
|
||||
}
|
||||
it("applies a function to a present value", () => {
|
||||
expect(sevens(3)).toEqual("777!");
|
||||
});
|
||||
it("passes through `null`", () => {
|
||||
expect(sevens(null)).toEqual(null);
|
||||
});
|
||||
it("passes through `undefined`", () => {
|
||||
expect(sevens(undefined)).toEqual(undefined);
|
||||
});
|
||||
it("treats `0` as present", () => {
|
||||
expect(sevens(0)).toEqual("!");
|
||||
});
|
||||
it("treats `false` as present", () => {
|
||||
expect(NullUtil.map(false, (x) => x === false)).toEqual(true);
|
||||
});
|
||||
it("treats `NaN` as present", () => {
|
||||
expect(NullUtil.map(NaN, (x) => isNaN(x))).toEqual(true);
|
||||
});
|
||||
it("treats an empty string as present", () => {
|
||||
expect(NullUtil.map("", (x) => x === "")).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("gets a normal present value", () => {
|
||||
expect((NullUtil.get((3: ?number)): number)).toEqual(3);
|
||||
});
|
||||
it("throws a default-message error on `null`", () => {
|
||||
expect(() => (NullUtil.get((null: ?number)): number)).toThrow(/^null$/);
|
||||
});
|
||||
it("throws a default-message error on `undefined`", () => {
|
||||
expect(() => (NullUtil.get((undefined: ?number)): number)).toThrow(
|
||||
/^undefined$/
|
||||
);
|
||||
});
|
||||
it("throws a custom error on `null`", () => {
|
||||
expect(() => (NullUtil.get((null: ?number), "uh oh"): number)).toThrow(
|
||||
/^uh oh$/
|
||||
);
|
||||
});
|
||||
it("throws a custom error on `undefined`", () => {
|
||||
expect(
|
||||
() => (NullUtil.get((undefined: ?number), "oh dear"): number)
|
||||
).toThrow(/^oh dear$/);
|
||||
});
|
||||
it("treats `0` as present", () => {
|
||||
expect(NullUtil.get(0)).toEqual(0);
|
||||
});
|
||||
it("treats `false` as present", () => {
|
||||
expect(NullUtil.get(false)).toEqual(false);
|
||||
});
|
||||
it("treats `NaN` as present", () => {
|
||||
expect(NullUtil.get(NaN)).toEqual(NaN);
|
||||
});
|
||||
it("treats the empty string as present", () => {
|
||||
expect(NullUtil.get("")).toEqual("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("orThrow", () => {
|
||||
function expectPresent<T>(x: T): void {
|
||||
const fn = jest.fn();
|
||||
expect((NullUtil.orThrow((x: ?T), fn): T)).toEqual(x);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
}
|
||||
it("throws the provided message on `null`", () => {
|
||||
const fn: () => string = jest.fn().mockReturnValueOnce("uh oh");
|
||||
expect(() => (NullUtil.orThrow((null: ?number), fn): number)).toThrow(
|
||||
/^uh oh$/
|
||||
);
|
||||
expect(fn.mock.calls).toEqual([[]]);
|
||||
});
|
||||
it("throws a custom error on `undefined`", () => {
|
||||
const fn: () => string = jest.fn().mockReturnValueOnce("oh dear");
|
||||
expect(
|
||||
() => (NullUtil.orThrow((undefined: ?number), fn): number)
|
||||
).toThrow(/^oh dear$/);
|
||||
expect(fn.mock.calls).toEqual([[]]);
|
||||
});
|
||||
it("gets a normal present value", () => {
|
||||
expectPresent(3);
|
||||
});
|
||||
it("treats `0` as present", () => {
|
||||
expectPresent(0);
|
||||
});
|
||||
it("treats `false` as present", () => {
|
||||
expectPresent(false);
|
||||
});
|
||||
it("treats `NaN` as present", () => {
|
||||
expectPresent(NaN);
|
||||
});
|
||||
it("treats the empty string as present", () => {
|
||||
expectPresent("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("orElse", () => {
|
||||
it("gets a normal present value", () => {
|
||||
expect((NullUtil.orElse((3: ?number), 17): number)).toEqual(3);
|
||||
});
|
||||
it("returns the default value given `null`", () => {
|
||||
expect((NullUtil.orElse((null: ?number), 17): number)).toEqual(17);
|
||||
});
|
||||
it("returns the default value given `undefined`", () => {
|
||||
expect((NullUtil.orElse((undefined: ?number), 17): number)).toEqual(17);
|
||||
});
|
||||
it("treats `0` as present", () => {
|
||||
expect(NullUtil.orElse(0, 17)).toEqual(0);
|
||||
});
|
||||
it("treats `false` as present", () => {
|
||||
expect(NullUtil.orElse(false, true)).toEqual(false);
|
||||
});
|
||||
it("treats `NaN` as present", () => {
|
||||
expect(NullUtil.orElse(NaN, 123)).toEqual(NaN);
|
||||
});
|
||||
it("treats the empty string as present", () => {
|
||||
expect(NullUtil.orElse("", "not me")).toEqual("");
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue