Skip to content

Commit

Permalink
feat: add utils package
Browse files Browse the repository at this point in the history
Forked from my personal utils package to get it going.

Signed-off-by: Dirk de Visser <github@dirkdevisser.nl>
dirkdev98 committed Jan 17, 2025
1 parent d37129c commit 637a9f9
Showing 21 changed files with 1,332 additions and 1 deletion.
31 changes: 31 additions & 0 deletions docs/decisions/002-utils-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Create a utils package

## Context

At some point it is bound to happen that we maintain 10 versions of `isNil` in this repo
and countless more in private projects. I’m currently on five implementations and
counting. So we need a place to put this stuff, the so-called 'utils'.

There are a few main problems often cited for these kinds of packages:

- Unclear rules of what is a util and what isn't.
- Hard to discover what exists in the utils package, often lacking documentation or
necessary context.

I have few arguments to counter the above. And don't really want to add any 'rules' on
what this package may contain. Which, unsurprisingly, results in the above.

## Decision

We are going to create, publish and maintain a `@lightbase/utils` package. This will
contain:

- Functional utilities and type-safety helpers like `isNil`, `isRecord`, `isRecordwith`,
`assertNotNil` etc.
- Common generic types like `UnionToIntersection`, `MaybePromise`, etc.

## Consequences

At some point in the future, we might want to add clear rules on what is and isn't a
utility that should be in this package. Until then, authors and reviewers should use
careful consideration before adding things.
22 changes: 22 additions & 0 deletions packages/utils/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
MIT License

Copyright (c) 2024- Dirk de Visser
Copyright (c) 2025- Lightbase B.V

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
114 changes: 114 additions & 0 deletions packages/utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# @lightbase/utils

Various utilities, some lodash like, some to aid with strict TypeScript. Also includes
various type utilities.

## Utilities

This library exports the utility functions in the following groups:

## Assertion functions

Type-narrowing assertions, that throw when the provided value is falsy. This uses
TypeScript assert operator.

### `assertIsNil`

Asserts that the provided value is `null` or `undefined`. Else throws the provided error.

### `assertNotNil`

Asserts that the provided value is **not** `null` or `undefined. Else throws the provided
error.

## 'Is' functions

Type-narrowing functions, returning truthy when the value is of the corresponding type.
These can be used to constrain type in if-else branches.

### `isNil`

Returns `true` when the provided value is `null` or `undefined`.

### `isRecord`

Returns `true` when the provided value is an object-like.

### `isRecordWith`

Returns `true` when the provided value is an object-like and has the provided keys.

## Invariants

Generic assertion functions that don't type-narrow. Can be used to create
business-specific validation functions to align business logic and related error return
types.

> [!INFO]
>
> If at some point TypeScript supports creating assertion functions via a generic
> function, without manually typing the resulting assertion, this system will be applied
> to asserts as well.
### `createInvariant`

Create an invariant function to execute business rules. Various options are supported:

- A predicate callback which is executed to determine if an invariant fails
- Various options for error customization:
- Static error messages.
- Customizable error messages on invariant invocation.
- A custom error constructor, function or static method with typed arguments on
invariant invocation.
- Partial application of the provided custom error.

## Types

This library exports the following utility types:

### `Prettify`

Force TypeScript to resolve the result of computed types. Can be used to improve the
readability in Quick-documentation popups and errors.

### `MaybePromise` and `MaybeArray`

Represent a value which may be wrapped in a `Promise` or `Array` respectively.

### `ExtractPromise` and `ExtractArray`

Extract the type wrapped in a `MaybePromise` or `MaybeArray` respectively.

### `Brand`

Brand types, so a general type like string is not assignable to a business constrainted
type like 'email' without going through an explicit cast. See the
[Zod](https://zod.dev/?id=brand) docs or the
[Total TypeScript article](https://www.totaltypescript.com/four-essential-typescript-patterns)
on this subject.

### `PickKeysThatExtends` and `OmitKeysThatExtend`

Like `Pick` and `Omit`, but instead of specifying keys, specify the type of value that
should be picked or omitted.

### `UnionToIntersection`

Map union types to an intersection type.

### `InferFunctionLikeParameters`

Extract the parameter types from class constructors or functions.

## Node.js

This package has a specific `@encapsula/util/node` export providing the following
utilities

### `createAsyncLocalStorage`

Wrapper around
[AsyncLocalStorage](https://nodejs.org/api/async_context.html#class-asynclocalstorage),
for read-only AsyncLocalStorage use. The main difference is the explicit `get` and
`getOptional` functions that wrap `AsyncLocalStorage#getStore`. `get`, throws an error
when not in the async-context of a store.
3 changes: 3 additions & 0 deletions packages/utils/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from "@lightbase/eslint-config";

export default defineConfig({});
35 changes: 35 additions & 0 deletions packages/utils/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@lightbase/utils",
"private": true,
"version": "0.0.1",
"type": "module",
"license": "MIT",
"bugs": {
"url": "https://github.com/lightbase/platforms/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/lightbase/platforms",
"directory": "packages/utils"
},
"files": ["dist/src"],
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/src/index.js"
},
"./node": {
"type": "./dist/src/node/index.d.ts",
"node": "./dist/src/node/index.js"
}
},
"scripts": {
"build": "tsc -p ./tsconfig.json",
"lint": "eslint . --fix --cache --cache-strategy content --cache-location .cache/eslint/ --color",
"lint:ci": "eslint .",
"test": "vitest",
"clean": "rm -rf ./.cache ./dist"
},
"dependencies": {}
}
62 changes: 62 additions & 0 deletions packages/utils/src/assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { constructError } from "./error.js";
import type { ErrorConstructor } from "./error.js";
import { isNil } from "./is.js";
import type { InferFunctionLikeParameters } from "./types.js";

/**
* Asserts that the provided value is null|undefined.
*
* Customize the error by providing an error message. Further customization can be done by
* providing a class or function and the appropriate arguments.
*/
export function assertIsNil(value: unknown): asserts value is null | undefined;

export function assertIsNil(
value: unknown,
errorMessage: string,
): asserts value is null | undefined;

export function assertIsNil<ErrorLike extends ErrorConstructor>(
value: unknown,
errorConstructor: ErrorLike,
...errorArguments: InferFunctionLikeParameters<ErrorLike>
): asserts value is null | undefined;

export function assertIsNil(
value: unknown,
errorMessageOrConstructor?: string | ErrorConstructor,
...args: Array<unknown>
): asserts value is null | undefined {
if (!isNil(value)) {
throw constructError(errorMessageOrConstructor ?? "Assertion failed.", args);
}
}

/**
* Asserts that the provided value is not null|undefined.
*
* Customize the error by providing an error message. Further customization can be done by
* providing a class or function and the appropriate arguments.
*/
export function assertNotNil<T>(value: T): asserts value is NonNullable<T>;

export function assertNotNil<T>(
value: T,
errorMessage: string,
): asserts value is NonNullable<T>;

export function assertNotNil<T, ErrorLike extends ErrorConstructor>(
value: T,
errorConstructor: ErrorLike,
...errorArguments: InferFunctionLikeParameters<ErrorLike>
): asserts value is NonNullable<T>;

export function assertNotNil<T>(
value: T,
errorMessageOrConstructor?: string | ErrorConstructor,
...args: Array<unknown>
): asserts value is NonNullable<T> {
if (isNil(value)) {
throw constructError(errorMessageOrConstructor ?? "Assertion failed.", args);
}
}
33 changes: 33 additions & 0 deletions packages/utils/src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

/**
* Accept any function that constructs a throwable. i.e an Error constructor or a `createError`
* function.
*/
export type ErrorConstructor =
| (abstract new (...args: any) => any)
| ((...args: any) => any);

/**
* Construct an error based on the provided string, class or function.
*/
export function constructError(
constructorFunctionOrMessage: string | ErrorConstructor,
args: Array<unknown> = [],
): unknown {
if (typeof constructorFunctionOrMessage === "string") {
return new Error(constructorFunctionOrMessage);
}

const isClass = Function.prototype.toString
.call(constructorFunctionOrMessage)
.startsWith("class");

if (isClass) {
// @ts-expect-error value is a constructor. Args should be typed at the API boundary.
return new constructorFunctionOrMessage(...args);
}

// @ts-expect-error value is a function. Args should be typed at the API boundary.
return constructorFunctionOrMessage(...args);
}
7 changes: 7 additions & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type * from "./types.js";

export { isNil, isRecord, isRecordWith } from "./is.js";

export { assertIsNil, assertNotNil } from "./assert.js";

export { createInvariant } from "./invariant.js";
189 changes: 189 additions & 0 deletions packages/utils/src/invariant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

import { constructError } from "./error.js";
import type { ErrorConstructor } from "./error.js";
import type { InferFunctionLikeParameters } from "./types.js";

/**
* Accept any predicate function.
*/
interface InvariantPredicate {
(v: any): boolean;
}

/**
* Check that the predicate only accepts a single parameter.
*/
type ValidatePredicateType<P extends InvariantPredicate> =
Parameters<P>["length"] extends 1 ? P
: () => {
"~invalid predicate": "Predicate functions can only accept 1 (one) parameter.";
};

/**
* Convert 'any' to 'unknown' of the predicate function.
*
* Since any is a top-type, 'extends any' doesn't work. So we use a 'hack'. Param can only
* intersect with 1 if Param is typed as 'any'. Which we can then check by checking if any
* value extends that intersection.
*/
type ExtractPredicateParameterType<P extends InvariantPredicate> =
P extends (v: infer Param) => boolean ?
0 extends 1 & Param ?
unknown
: Param
: never;

/**
* Get the remainder of the tuple, based on the provided full tuple and start of the tuple.
*/
type InferRestTuple<Full extends Array<unknown>, Start extends Array<unknown>> =
Start["length"] extends 0 ? Full
: Start[0] extends Full[0] ?
Full extends [infer _, ...infer FullRest] ?
Start extends [infer _, ...infer StartRest] ?
InferRestTuple<FullRest, StartRest>
: never
: never
: never;

/**
* Create an invariant function with a customizable error to throw.
* When the predicate function returns a falsy value, an error is created and thrown.
*
* If no options are passed for error customization an plain error is thrown, with an
* optionally provided message via the return invariant function.
*
* @example
* ```ts
* const myInvariant = createInvariant({ predicate: isNil });
* myInvariant(true); // throw Error("Invariant failed");
* myInvariant(null, "Customized unused error message"); // Passes
* ```
*
* A static error message can be provided as well:
*
* @example
* ```ts
* const myInvariant = createInvariant({ predicate: isNil, errorMessage: "My error", });
* myInvariant(true); // throw Error("My error");
* myInvariant(null); // Passes
* ```
*
* Customizing the thrown error is possible via the 'errorConstructor'. It accepts any class,
* function or static class method to construct the error.
*
* @example
* ```ts
* const myInvariant = createInvariant({ predicate: isNil, errorConstructor: Error });
* myInvariant(true, "My Error", { cause: e }); // throw Error("My Error", { cause: e });
*
* class MyError extends Error {
* constructor(public status: number) {
* super();
* }
* }
*
* const myErrorInvariant = createInvariant({ predicate: isNil, errorConstructor: MyError });
* myErrorInvariant(true, 404); // throw MyError(404);
* ```
*
* A function or static method can also be used to construct the error:
*
* @example
* ```ts
* function createError(isClientProblem: boolean) {
* return new Error(`Problem of: ${isClientProblem ? "client" : "server"}`);
* }
*
* const myErrorInvariant = createInvariant({ predicate: isNil, errorConstructor: createError
* }); myErrorInvariant(5, true); // throw Error("Problem of client");
* ```
*
* In all cases, some arguments can be provided statically:
*
* @example
* ```ts
* class MyError {
* constructor(public status: number, public message: string) {
* super();
* }
* }
*
* const myErrorInvariant = createInvariant({
* predicate: isNil,
* errorConstructor: MyError,
* errorArguments: [400],
* });
* myErrorInvariant(true, "Oops something went wrong"); // throw MyError(400, "Oops something
* went wrong");
* ```
*/
export function createInvariant<const Predicate extends InvariantPredicate>(options: {
predicate: ValidatePredicateType<Predicate>;
}): (v: ExtractPredicateParameterType<Predicate>, errorMessage?: string) => void;

export function createInvariant<const Predicate extends InvariantPredicate>(options: {
predicate: ValidatePredicateType<Predicate>;
errorMessage: string;
}): (v: ExtractPredicateParameterType<Predicate>) => void;

export function createInvariant<
const Predicate extends InvariantPredicate,
const ErrorLike extends ErrorConstructor,
>(options: {
predicate: ValidatePredicateType<Predicate>;
errorConstructor: ErrorLike;
}): (
v: ExtractPredicateParameterType<Predicate>,
...errorArguments: InferFunctionLikeParameters<ErrorLike>
) => void;

export function createInvariant<
const Predicate extends InvariantPredicate,
const ErrorLike extends ErrorConstructor,
const ErrorArguments extends Partial<InferFunctionLikeParameters<ErrorLike>>,
>(options: {
predicate: ValidatePredicateType<Predicate>;
errorConstructor: ErrorLike;
errorArguments: ErrorArguments;
}): (
v: ExtractPredicateParameterType<Predicate>,
...errorArguments: InferRestTuple<
InferFunctionLikeParameters<ErrorLike>,
ErrorArguments
>
) => void;

export function createInvariant<const Predicate extends InvariantPredicate>(options: {
predicate: ValidatePredicateType<Predicate>;
errorConstructor?: ErrorConstructor;
errorArguments?: Array<unknown>;
errorMessage?: string;
}): (
v: ExtractPredicateParameterType<Predicate>,
...invariantArguments: Array<unknown>
) => void {
const errorArguments = options.errorArguments ?? [];

return (v: unknown, ...invariantArguments: Array<unknown>): void => {
const predicateResult = options.predicate(v);
if (predicateResult) {
return;
}

if (options.errorConstructor) {
throw constructError(options.errorConstructor, [
...errorArguments,
...invariantArguments,
]);
}

let message = options.errorMessage;
if (!message && typeof invariantArguments[0] === "string") {
message = invariantArguments[0];
}

throw constructError(message ?? "Invariant failed.");
};
}
23 changes: 23 additions & 0 deletions packages/utils/src/is.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Check if the provided value is null|undefined.
*/
export function isNil(value: unknown): value is null | undefined {
return value === null || value === undefined;
}

/**
* Check if the provided value is an object-like
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
* Check if the provided value is an object-like with the provided keys
*/
export function isRecordWith<T extends string>(
value: unknown,
keys: Array<T>,
): value is Record<T & string, unknown> {
return isRecord(value) && keys.every((key) => Object.hasOwn(value, key));
}
46 changes: 46 additions & 0 deletions packages/utils/src/node/async-local-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { assertNotNil } from "../assert.js";

/**
* Wrapper around AsyncLocalStorage.
*
* The main difference is the explicit `get` and
* `getOptional` functions that wrap `AsyncLocalStorage#getStore`. `get`, throws an error
* when not in the async-context of a store.
*/
export function createAsyncLocalStorage<Type>(name: string) {
const storage = new AsyncLocalStorage<Type>();

return {
/**
* Like {@link AsyncLocalStorage#getStore}, but adds a non-null assertion when not running
* in the async context.
*/
get() {
const result = storage.getStore();
assertNotNil(result, `No value present in the ${name} storage.`);
return result;
},

/**
* Wrapper around {@link AsyncLocalStorage#getStore}
*/
getOptional() {
return storage.getStore();
},

/**
* Wrapper around {@link AsyncLocalStorage#run}
*/
run<ReturnType>(value: Type, fn: () => ReturnType) {
return storage.run<ReturnType>(value, fn);
},

/**
* Wrapper around {@link AsyncLocalStorage#exit}
*/
exit<ReturnType>(fn: () => ReturnType) {
return storage.exit(fn);
},
};
}
1 change: 1 addition & 0 deletions packages/utils/src/node/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createAsyncLocalStorage } from "./async-local-storage.js";
171 changes: 171 additions & 0 deletions packages/utils/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Let TypeScript resolve nested types, so type-information shows resolved types in Quick
* Type popups and error messages.
*
* https://x.com/mattpocockuk/status/1622730173446557697
*/
export type Prettify<T> = { [K in keyof T]: T[K] } & {};

/**
* Represents a value which may be wrapped in a Promise.
*/
export type MaybePromise<T> = T | Promise<T>;

/**
* Extract the Promise type. If T is not a Promise, T is returned.
*/
export type ExtractPromise<T> = T extends Promise<infer N> ? N : T;

/**
* Represents a value which may be alone or in an array.
*/
export type MaybeArray<T> = T | Array<T>;

/**
* Extract the Array type. If T is not an Array, T is returned.
*/
export type ExtractArray<T> = T extends Array<infer N> ? N : T;

/**
* Brand types, so an unvalidated primitive is not allowed.
*
* @example
* ```
* type Age = Brand<number, "age">;
*
* export function makeAge(value: number): Age {
* if (value >= 0 && value <= 120) {
* return value as Age;
* }
*
* throw new Error("Invalid value for 'age'");
* }
*
* function isOlderThan21(age: Age) {
* return age > 21;
* }
*
* // => no type errors
* isOlderThan21(makeAge(22));
*
* // => type error number is not assignable to Age
* isOlderThan21(18);
* ```
*/
export type Brand<Type, Brand extends string> = Type &
Readonly<Record<`__brand_${Brand}`, never>>;

/**
* Selector for {@link PickKeysThatExtend} and {@link OmitKeysThatExtend}.
*/
export type PickOmitVersions = "ValueExtendsSelect" | "SelectExtendsValue";

type RunPickOmitVersions<A, B, Selector extends PickOmitVersions> =
Selector extends "ValueExtendsSelect" ?
A extends B ?
true
: false
: B extends A ? true
: false;

/**
* Extract from T, the keys that point to a value that extend Select.
*
* If `SelectExtendsValue` is used as the Selector, the keys are extracted for which the Select
* extends the Value.
*
* @example
* ```
* type Foo = {
* bar: boolean;
* baz: string;
* };
*
* type BooleanFoo = PickKeysThatExtend<Foo, boolean>;
* //? { bar: boolean }
* ```
*/
export type PickKeysThatExtend<
T,
Select,
Selector extends PickOmitVersions = "ValueExtendsSelect",
> = {
[K in keyof T as RunPickOmitVersions<T[K], Select, Selector> extends true ? K
: never]: T[K];
};

/**
* Omit from T, the keys that point a value that extend Select.
*
* If `SelectExtendsValue` is used as the Selector, the keys are omitted for which the Select
* extends the Value.
*
*
* @example
* ```
* type Foo = {
* bar: boolean;
* baz: string;
* };
*
* type BooleanFoo = OmitKeysThatExtend<Foo, boolean>;
* //? { baz: string }
* ```
*/
export type OmitKeysThatExtend<
T,
Select,
Selector extends PickOmitVersions = "ValueExtendsSelect",
> = {
[K in keyof T as RunPickOmitVersions<T[K], Select, Selector> extends true ? never
: K]: T[K];
};

/**
* Exclude from object Type, the keys that are assignable to object B.
*
* @example
* ```ts
* type A = { a: string, b: string };
* type B = { a: string };
*
* type C = ExcludeRecords<A, B>;
* //? { b: string }
* ```
*/
export type ExcludeRecords<A, B> = UnionToIntersection<
Exclude<
{
[K in keyof A]: Record<K, A[K]>;
}[keyof A],
{
[K in keyof B]: Record<K, B[K]>;
}[keyof B]
>
>;

/**
* Convert an union type to an intersection type.
*
* @example
* ```
* type Foo = { bar: string } | { baz: string };
*
* type Intersected = UnionToIntersection<Foo>;
* //? { bar: string } & { baz: string }
* ```
*/
// From https://stackoverflow.com/a/50375286 CC BY-SA 4.0
export type UnionToIntersection<U> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;

/**
* Extract constructor like or function parameters
*/
export type InferFunctionLikeParameters<T> =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends abstract new (...args: infer P) => any ? P
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends (...args: infer P) => any ? P
: never;
81 changes: 81 additions & 0 deletions packages/utils/test/assert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { assertIsNil, assertNotNil } from "../src/assert.js";
import { isNilTestCases } from "./is.test.js";

describe("assertIsNil", () => {
it.for([...isNilTestCases])(
"runs through the stand isNil test cases - %s",
({ input, expected }, { expect }) => {
if (expected) {
expect(() => {
assertIsNil(input);
}).not.toThrow();
} else {
expect(() => {
assertIsNil(input);
}).toThrow("Assertion failed.");
}
},
);

it("throws with the provided string", () => {
expect(() => {
assertIsNil("", "my error");
}).toThrow("my error");
});

it("throws with the provided class", () => {
class Z {}

expect(() => {
assertIsNil("", Z);
}).toThrow(Z);
});

it("throws with the provided function and arguments", () => {
const e = (arg: string) => new Error(arg);

expect(() => {
assertIsNil("", e, "foo");
}).toThrowErrorMatchingInlineSnapshot(`[Error: foo]`);
});
});

describe("assertNotNil", () => {
it.for([...isNilTestCases])(
"runs through the stand isNil test cases - %s",
({ input, expected }, { expect }) => {
if (expected) {
expect(() => {
assertNotNil(input);
}).toThrow("Assertion failed.");
} else {
expect(() => {
assertNotNil(input);
}).not.toThrow();
}
},
);

it("throws with the provided string", () => {
expect(() => {
assertNotNil(null, "my error");
}).toThrow("my error");
});

it("throws with the provided class", () => {
class Z {}

expect(() => {
assertNotNil(undefined, Z);
}).toThrow(Z);
});

it("throws with the provided function and arguments", () => {
const e = (arg: string) => new Error(arg);

expect(() => {
assertNotNil(undefined, e, "foo");
}).toThrowErrorMatchingInlineSnapshot(`[Error: foo]`);
});
});
293 changes: 293 additions & 0 deletions packages/utils/test/invariant.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import { isNil } from "../src/index.js";
import { createInvariant } from "../src/invariant.js";

describe("createInvariant", () => {
it("returns a callable function", () => {
const invariant = createInvariant({
predicate: isNil,
});

expect(invariant).toBeTypeOf("function");
});

describe("invariant with customizable error message", () => {
const invariant = createInvariant({
predicate: isNil,
});

it("has the correct type", () => {
expectTypeOf(invariant).toEqualTypeOf<(value: unknown, message?: string) => void>();
});

it("uses the type of the predicate when available", () => {
type User = { id: string };

expectTypeOf(createInvariant({ predicate: (_u: User) => false })).toEqualTypeOf<
(value: User, message?: string) => void
>();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
expectTypeOf(createInvariant({ predicate: (_u: any) => false })).toEqualTypeOf<
(value: unknown, message?: string) => void
>();
});

it("can be called", () => {
expect(() => invariant(null)).not.toThrowError();
});

it("throws with a default message when the predicate fails", () => {
expect(() => invariant(true)).toThrowErrorMatchingInlineSnapshot(
`[Error: Invariant failed.]`,
);
});

it("throws with the provided message when the predicate fails", () => {
expect(() =>
invariant(true, "Custom error message"),
).toThrowErrorMatchingInlineSnapshot(`[Error: Custom error message]`);
});
});

describe("invariant with static error message", () => {
const invariant = createInvariant({
predicate: isNil,
errorMessage: "Static error",
});

it("has the correct type", () => {
expectTypeOf(invariant).toEqualTypeOf<(value: unknown) => void>();
});

it("uses the type of the predicate when available", () => {
type User = { id: string };

expectTypeOf(
createInvariant({
predicate: (_u: User) => false,
errorMessage: "Static message",
}),
).toEqualTypeOf<(value: User) => void>();

expectTypeOf(
createInvariant({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
predicate: (_u: any) => false,
errorMessage: "Static message",
}),
).toEqualTypeOf<(value: unknown) => void>();
});

it("can be called", () => {
expect(() => invariant(null)).not.toThrowError();
});

it("throws with the static message when the predicate fails", () => {
expect(() => invariant(true)).toThrowErrorMatchingInlineSnapshot(
`[Error: Static error]`,
);
});
});

describe("invariant with parameter-less error constructor", () => {
class MyError {
public message = "Some message";
constructor() {}
}

const invariant = createInvariant({
predicate: isNil,
errorConstructor: MyError,
});

it("has the correct type", () => {
expectTypeOf(invariant).toEqualTypeOf<(value: unknown) => void>();
});

it("uses the type of the predicate when available", () => {
type User = { id: string };

expectTypeOf(
createInvariant({
predicate: (_u: User) => false,
errorConstructor: MyError,
}),
).toEqualTypeOf<(value: User) => void>();

expectTypeOf(
createInvariant({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
predicate: (_u: any) => false,
errorConstructor: MyError,
}),
).toEqualTypeOf<(value: unknown) => void>();
});

it("can be called", () => {
expect(() => invariant(null)).not.toThrowError();
});

it("throws with the custom errorConstructor", () => {
expect(() => invariant(true)).toThrowErrorMatchingInlineSnapshot(`
MyError {
"message": "Some message",
}
`);
});
});

describe("invariant with parameter-less error constructor function", () => {
class MyError {
public message = "Some message";
constructor() {}
}

const createError = () => new MyError();

const invariant = createInvariant({
predicate: isNil,
errorConstructor: createError,
});

it("has the correct type", () => {
expectTypeOf(invariant).toEqualTypeOf<(value: unknown) => void>();
});

it("uses the type of the predicate when available", () => {
type User = { id: string };

expectTypeOf(
createInvariant({
predicate: (_u: User) => false,
errorConstructor: createError,
}),
).toEqualTypeOf<(value: User) => void>();

expectTypeOf(
createInvariant({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
predicate: (_u: any) => false,
errorConstructor: createError,
}),
).toEqualTypeOf<(value: unknown) => void>();
});

it("can be called", () => {
expect(() => invariant(null)).not.toThrowError();
});

it("throws with the custom errorConstructor", () => {
expect(() => invariant(true)).toThrowErrorMatchingInlineSnapshot(`
MyError {
"message": "Some message",
}
`);
});
});

describe("invariant with parameterized error constructor", () => {
class MyError {
constructor(
public status: number,
public key: string,
) {}
}

const invariant = createInvariant({
predicate: isNil,
errorConstructor: MyError,
});

it("has the correct type", () => {
expectTypeOf(invariant).toEqualTypeOf<
(value: unknown, status: number, key: string) => void
>();
});

it("uses the type of the predicate when available", () => {
type User = { id: string };

expectTypeOf(
createInvariant({
predicate: (_u: User) => false,
errorConstructor: MyError,
}),
).toEqualTypeOf<(value: User, status: number, key: string) => void>();

expectTypeOf(
createInvariant({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
predicate: (_u: any) => false,
errorConstructor: MyError,
}),
).toEqualTypeOf<(value: unknown, status: number, key: string) => void>();
});

it("can be called", () => {
expect(() => invariant(null, 404, "oops")).not.toThrowError();
});

it("throws with the custom errorConstructor", () => {
expect(() => invariant(true, 500, "oops")).toThrowErrorMatchingInlineSnapshot(`
MyError {
"key": "oops",
"status": 500,
}
`);
});
});

describe("invariant with partial applied parameterized error constructor", () => {
class MyError {
constructor(
public status: number,
public key: string,
) {}
}

const invariant = createInvariant({
predicate: isNil,
errorConstructor: MyError,
errorArguments: [500],
});

it("has the correct type", () => {
expectTypeOf(invariant).toEqualTypeOf<(value: unknown, key: string) => void>();
});

it("uses the type of the predicate when available", () => {
type User = { id: string };

expectTypeOf(
createInvariant({
predicate: (_u: User) => false,
errorConstructor: MyError,
errorArguments: [500],
}),
).toEqualTypeOf<(value: User, key: string) => void>();

expectTypeOf(
createInvariant({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
predicate: (_u: any) => false,
errorConstructor: MyError,
errorArguments: [500],
}),
).toEqualTypeOf<(value: unknown, key: string) => void>();
});

it("can be called", () => {
expect(() => invariant(null, "oops")).not.toThrowError();
});

it("throws with the custom errorConstructor", () => {
expect(() => invariant(true, "bar")).toThrowErrorMatchingInlineSnapshot(`
MyError {
"key": "bar",
"status": 500,
}
`);
});
});
});
64 changes: 64 additions & 0 deletions packages/utils/test/is.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it } from "vitest";
import { isNil, isRecord } from "../src/is.js";

export const isNilTestCases = [
{ input: null, expected: true },
{ input: undefined, expected: true },
{ input: true, expected: false },
{ input: false, expected: false },
{ input: "", expected: false },
{ input: "asdfa", expected: false },
{ input: 0, expected: false },
{ input: 5, expected: false },
{ input: -5, expected: false },
{ input: [], expected: false },
{ input: [1, 2, 3], expected: false },
{ input: Array(4), expected: false },
{ input: {}, expected: false },
{ input: { foo: "bar" }, expected: false },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ input: Object.create(null), expected: false },
{ input: class {}, expected: false },
{ input: new (class {})(), expected: false },
{ input: () => {}, expected: false },
];

describe("isNil", () => {
it.for([...isNilTestCases])(
"runs through the test cases '%s'",
({ input, expected }, { expect }) => {
expect(isNil(input)).toBe(expected);
},
);
});

export const isRecordTestCases = [
{ input: null, expected: false },
{ input: undefined, expected: false },
{ input: true, expected: false },
{ input: false, expected: false },
{ input: "", expected: false },
{ input: "asdfa", expected: false },
{ input: 0, expected: false },
{ input: 5, expected: false },
{ input: -5, expected: false },
{ input: [], expected: false },
{ input: [1, 2, 3], expected: false },
{ input: Array(4), expected: false },
{ input: {}, expected: true },
{ input: { foo: "bar" }, expected: true },
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ input: Object.create(null), expected: true },
{ input: class {}, expected: false },
{ input: new (class {})(), expected: true },
{ input: () => {}, expected: false },
];

describe("isRecord", () => {
it.for([...isRecordTestCases])(
"runs through the test cases '%s'",
({ input, expected }, { expect }) => {
expect(isRecord(input)).toBe(expected);
},
);
});
39 changes: 39 additions & 0 deletions packages/utils/test/node/async-local-storage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import { createAsyncLocalStorage } from "../../src/node/index.js";

describe("createAsyncLocalStorage", () => {
it("throws when .get is called without a value", () => {
const localStorage = createAsyncLocalStorage("test");

expect(() => {
localStorage.get();
}).toThrow("No value present in the test storage");
});

it("does not throw when .getOptional is called without a value", () => {
const localStorage = createAsyncLocalStorage("test");

expect(() => {
expect(localStorage.getOptional()).toBeUndefined();
}).not.toThrow();
});

it("returns the value the local storage was entered with", async () => {
const localStorage = createAsyncLocalStorage<string>("test");

const mockFn = vi.fn(() => {
expect(localStorage.get()).toBe("foo");
});

await localStorage.run("foo", async () => {
await new Promise<void>((r) => {
r();
mockFn();
});

mockFn();
});

expect(mockFn).toHaveBeenCalledTimes(2);
});
});
105 changes: 105 additions & 0 deletions packages/utils/test/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expectTypeOf, it } from "vitest";
import type { OmitKeysThatExtend, PickKeysThatExtend, Prettify } from "../src/types.js";

describe("Prettify", () => {
it("returns the same primitive", () => {
expectTypeOf<Prettify<true>>().toMatchTypeOf<boolean>();
expectTypeOf<Prettify<"foo">>().toMatchTypeOf<string>();
expectTypeOf<Prettify<1>>().toMatchTypeOf<number>();
expectTypeOf<Prettify<null>>().toMatchTypeOf<null>();
});

it("returns the same object-like types", () => {
expectTypeOf<
Prettify<
Pick<
{
foo: "bar";
},
"foo"
>
>
>().toEqualTypeOf<{ foo: "bar" }>();
expectTypeOf<
Prettify<
Pick<
{
foo: "bar";
} & { bar: true },
"foo"
>
>
>().toEqualTypeOf<{ foo: "bar" }>();
});
});

describe("PickKeysThatExtend", () => {
it("returns an object like with keys that extend the provided type", () => {
expectTypeOf<
PickKeysThatExtend<
{
foo: string;
bar: boolean;
},
string
>
>().toEqualTypeOf<{ foo: string }>();
});
});

describe("OmitKeysThatExtend", () => {
it("returns an object like with keys that do not extend the provided type", () => {
expectTypeOf<
OmitKeysThatExtend<
{
foo: string;
bar: boolean;
},
string
>
>().toEqualTypeOf<{ bar: boolean }>();
});

it("omits possible undefined keys", () => {
expectTypeOf<
OmitKeysThatExtend<
{
foo: string;
bar?: boolean;
},
undefined,
"SelectExtendsValue"
>
>().toEqualTypeOf<{
foo: string;
}>();
});

it("omits ValueExtendsSelect", () => {
expectTypeOf<
OmitKeysThatExtend<
{
foo: 42;
bar: number;
quix: string;
},
number
>
>().toEqualTypeOf<{ quix: string }>();
});

it("omits SelectExtendsValue", () => {
expectTypeOf<
OmitKeysThatExtend<
{
foo: 42;
bar: number;
},
number,
"SelectExtendsValue"
>
>().toEqualTypeOf<{
foo: 42;
}>();
});
});
8 changes: 8 additions & 0 deletions packages/utils/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "@total-typescript/tsconfig/tsc/no-dom/library-monorepo",
"compilerOptions": {
"outDir": "dist"
},
"include": ["**/*"],
"references": []
}
3 changes: 2 additions & 1 deletion release-please-config.json
Original file line number Diff line number Diff line change
@@ -2,7 +2,8 @@
"release-type": "node",
"packages": {
"packages/eslint-config": {},
"packages/pull-through-cache": {}
"packages/pull-through-cache": {},
"packages/utils": {}
},
"plugins": [
{
3 changes: 3 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -22,6 +22,9 @@
},
{
"path": "./packages/pull-through-cache"
},
{
"path": "./packages/utils"
}
]
}

0 comments on commit 637a9f9

Please sign in to comment.