Skip to content

Commit

Permalink
Add isOneOf
Browse files Browse the repository at this point in the history
  • Loading branch information
xuhdev committed Apr 6, 2024
1 parent 5604b8f commit cf76e54
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@

export * from "./array.js";
export * from "./error.js";
export * from "./is-one-of.js";
export * from "./type-guard.js";
export * from "./type-of.js";

export type * from "./array.d.ts";
export type * from "./error.d.ts";
export type * from "./is-one-of.d.ts";
export type * from "./type-guard.d.ts";
export type * from "./type-of.d.ts";
52 changes: 52 additions & 0 deletions src/is-one-of.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/** @license Apache-2.0
*
* Copyright 2024 8 Hobbies, LLC <[email protected]>
*
* Licensed under the Apache License, Version 2.0(the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { isOneOf } from "./is-one-of.js";

describe("isOneOf applied to a collection of literals", () => {
for (const collection of [
["a", "b"] as const,
new Set(["a", "b"] as const),
]) {
test(`${collection.toString()} including with non-literal element type returns true`, () => {
expect(isOneOf("a", collection)).toBe(true);
});

test(`${collection.toString()} not including with non-literal element type returns false`, () => {
expect(isOneOf("c", collection)).toBe(false);
});

test(`${collection.toString()} including with literal element type leads to type error`, () => {
// @ts-expect-error
expect(isOneOf("a" as const, collection)).toBe(true);
});

test(`${collection.toString()} not including literal element type leads to type error`, () => {
// @ts-expect-error
expect(isOneOf("c" as const, collection)).toBe(false);
});
}
});

describe("isOneOf applied to unknown collection type", () => {
test("throw error", () => {
expect(() => {
// @ts-expect-error
isOneOf("a", 100);
}).toThrow(/Unreachable/);
});
});
123 changes: 123 additions & 0 deletions src/is-one-of.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/** Utilities for determining whether a value is in a given {@link !Array}/{@link !Set}.
*
* JavaScript {@link !Array.includes} and {@link !Set.has} determine whether a value is in a given
* {@link !Array}/{@link !Set}. They are typed such that the parameter must be assignable to the
* element type of the {@link !Array}/{@link !Set}:
*
* ```ts
* const fruits = ["apple", "orange", "grape"]; // or new Set(["apple", "orange", "grape"])
* const fireball: string = "fire ball";
* const containingFireball = fruits.includes(fireball); // or fruits.has(fireball); OK
* const containingNumber1 = fruits.includes(1); // or fruits.has(1);
* // error: Argument of type 'number' is not assignable to parameter of type 'string'.
* ```
*
* This makes sense, because when `fruits` is dynamically constructed by the logic of the program,
* an unmatched element type is not intended and likely an error. However, the third line in the
* above code snippet would fail to compile if array contains literal types:
*
* ```ts
* const fruits = ["apple", "orange", "grape"] as const; // or new Set(["apple", "orange", "grape"] as const);
* const fireball: string = "fire ball";
* const containingFireball = fruits.includes(fireball); // or fruits.has(fireball);
* // error: Argument of type 'string' is not assignable to parameter of
* // type '"apple" | "orange" | "grape"'.
* ```
*
* This doesn't make sense because the semantics has changed: `fireball` doesn't have to have one of
* the literal string types in `fruits`.
*
* To address this issue, this module provides a {@link isOneOf} function that is typed to
* accommodate the second scenario above, while retains the same logic as {@link !Array.includes}
* and {@link !Set.has}:
*
* ```ts
* const fruits = ["apple", "orange", "grape"] as const; // or new Set(["apple", "orange", "grape"] as const);
* const fireball: string = "fire ball";
* const containsFireball = isOneOf(fireball, fruits); // OK
* ```
*
* For the first scenario, you should continue using {@link !Array.includes} and {@link !Set.has}.
*
* @module is-one-of
*/

/** @license Apache-2.0
*
* Copyright 2024 8 Hobbies, LLC <[email protected]>
*
* Licensed under the Apache License, Version 2.0(the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/** Returns whether `array` includes `element`.
*
* Logically this is the same as `array.includes(element)`, but is typed differently to accommondate
* the scenario in which `array` contains literal-type elements. See {@link is-one-of} for details.
*
* @typeParam T - The type of `element`.
* @param element - The element to be determined whether it is included in `array`.
* @param array - The array to be determined whether it includes `element`.
* @returns Whether whether `array` includes `element`.
* @see {@link is-one-of}
*/
export function isOneOf<T>(
element: T,
array: Readonly<Array<NoInfer<T>>>,
): boolean;

/** Returns whether `set` includes `element`.
*
* Logically this is the same as `set.has(element)`, but is typed differently to accommodate the
* scenario in which `set` contains literal-type elements. See {@link is-one-of} for details.
*
* @typeParam T - The type of `element`.
* @param element - The element to be determined whether it is included in `set`.
* @param set - The set to be determined whether it includes `element`.
* @returns Whether whether `set` includes `element`.
* @see {@link is-one-of}
*/
export function isOneOf<T>(element: T, set: Readonly<Set<NoInfer<T>>>): boolean;

/** A catch-all overload of the other two overloads. Check out the other two overloads for details.
*
* This overload addresses the edge case in which `collection` may be either a {@link Array} or
* {@link Set}, and is expected to be used rarely.
*
* @example
* ```ts
* for (const collection of [
* ["apple", "orange"] as const,
* new Set(["green", "red"] as const),
* ]) {
* isOneOf("sky", collection);
* // collection is of type readonly ["apple", "orange"] | Set<"green" | "red">
* }
* ```
*/
export function isOneOf<T>(
element: T,
collection: Readonly<Array<NoInfer<T>>> | Readonly<Set<NoInfer<T>>>,
): boolean;

/** @hidden */
export function isOneOf<T>(
element: T,
collection: Readonly<Array<NoInfer<T>>> | Readonly<Set<NoInfer<T>>>,
): boolean {
if (Array.isArray(collection)) {
return collection.includes(element);
} else if (collection instanceof Set) {
return collection.has(element);
}
throw new Error("Unreachable code reached");
}
1 change: 1 addition & 0 deletions typedoc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"entryPoints": [
"./src/array.ts",
"./src/error.ts",
"./src/is-one-of.ts",
"./src/type-guard.ts",
"./src/type-of.ts"
],
Expand Down

0 comments on commit cf76e54

Please sign in to comment.