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:
William Chargin 2018-07-09 14:47:10 -07:00 committed by GitHub
parent fed58aee7b
commit 4af8ff2471
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 210 additions and 0 deletions

81
src/util/null.js Normal file
View File

@ -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;
}

129
src/util/null.test.js Normal file
View File

@ -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("");
});
});
});