From 4af8ff2471d715ef7136fc9b7756e2ee548cd6c5 Mon Sep 17 00:00:00 2001 From: William Chargin Date: Mon, 9 Jul 2018 14:47:10 -0700 Subject: [PATCH] Add utilities for working with nullable types (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/util/null.js | 81 ++++++++++++++++++++++++++ src/util/null.test.js | 129 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/util/null.js create mode 100644 src/util/null.test.js diff --git a/src/util/null.js b/src/util/null.js new file mode 100644 index 0000000..54716b6 --- /dev/null +++ b/src/util/null.js @@ -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` 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(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(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(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(x: ?T, defaultValue: T): T { + return x != null ? x : defaultValue; +} diff --git a/src/util/null.test.js b/src/util/null.test.js new file mode 100644 index 0000000..1f73636 --- /dev/null +++ b/src/util/null.test.js @@ -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(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(""); + }); + }); +});