Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ structure.

<!-- assertion-functions:start -->

- [assertArrayEmpty](src/assert/array-empty/array-empty.assert.ts)
- [assertArrayEquals](src/assert/array-equals/array-equals.assert.ts)
- [assertArrayIncludesAll](src/assert/array-includes-all/array-includes-all.assert.ts)
- [assertArrayIncludes](src/assert/array-includes/array-includes.assert.ts)
Expand Down Expand Up @@ -137,6 +138,7 @@ structure.

<!-- matcher-functions:start -->

- [emptyArray](src/assert/array-empty/array-empty.match.ts)
- [arrayIncludingAll](src/assert/array-includes-all/array-includes-all.match.ts)
- [arrayIncluding](src/assert/array-includes/array-includes.match.ts)
- [arrayOfLength](src/assert/array-length/array-length.match.ts)
Expand Down
50 changes: 50 additions & 0 deletions src/assert/array-empty/array-empty.assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { emptyArray } from "./array-empty.match.js";
import { AssertionError } from "../../assertion-error.js";
import { desc } from "../../describe/describe.js";
import type { EmptyArray, EmptyArrayMatch } from "./array-empty.type.js";

export function assertArrayEmpty<TActual extends object | null | undefined>(
value: TActual,
message?: string,
): asserts value is Extract<NonNullable<TActual>, readonly unknown[]> &
EmptyArrayMatch<Extract<NonNullable<TActual>, readonly unknown[]>>;

export function assertArrayEmpty(
value: unknown,
message?: string,
): asserts value is EmptyArray;

/**
* Assert that an array has no elements, with type-narrowing.
* The failure message names the elements that are present, which is usually
* the diagnostic the assertion exists to produce.
* @example
* ```ts
* import { assertArrayEmpty } from "@kensio/smartass";
*
* const value: unknown = [];
*
* assertArrayEmpty(value);
*
* // value is now narrowed to an empty array
* ```
*/
export function assertArrayEmpty(value: unknown, message?: string): void {
const matcher = emptyArray();

if (!matcher.isMatch(value)) {
throw new AssertionError(
message ?? buildArrayEmptyMessage(value),
value,
matcher.represent(),
);
}
}

function buildArrayEmptyMessage(value: unknown): string {
if (!Array.isArray(value)) {
return `Expected ${desc(value)} to be an empty array.`;
}

return `Expected ${desc(value)} to be empty.`;
}
43 changes: 43 additions & 0 deletions src/assert/array-empty/array-empty.match.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createMatcher } from "../../match/match.js";
import {
emptyArrayMatcher,
type EmptyArray,
type EmptyArrayMatcher,
} from "./array-empty.type.js";

/**
* Matcher for an empty array.
* Matchers are applied through assertObjectMatches, where they narrow the
* corresponding property type.
* Type information that already exists in the calling scope is incorporated.
* @example
* ```ts
* import { assertObjectMatches, emptyArray } from "@kensio/smartass";
*
* const value: unknown = {
* errors: [],
* };
*
* assertObjectMatches(value, {
* errors: emptyArray(),
* });
*
* // value is now narrowed to an object with an empty errors array
* // {
* // errors: [];
* // }
* ```
*/
export function emptyArray(): EmptyArrayMatcher {
return {
...createMatcher(
(value): value is EmptyArray =>
Array.isArray(value) && value.length === 0,
() => `empty array`,
() => `[]`,
),
// Runtime marker used only to make the matcher type nominal for type-level
// refinement dispatch. It is not part of the user-facing matcher behaviour.
[emptyArrayMatcher]: true,
};
}
163 changes: 163 additions & 0 deletions src/assert/array-empty/array-empty.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import { assertArrayEmpty } from "./array-empty.assert.js";
import type { AssertionError } from "../../assertion-error.js";
import { emptyArray } from "./array-empty.match.js";
import { desc, repr } from "../../describe/describe.js";
import { assertObjectMatches } from "../object-matches/object-matches.assert.js";

describe("empty", () => {
describe("assertArrayEmpty", () => {
it("passes on an empty array", () => {
expect(() => {
assertArrayEmpty([]);
}).not.toThrow();
});

it("names the elements that are present", () => {
let error: AssertionError;
try {
assertArrayEmpty(["first", "second"]);
expect.unreachable();
} catch (error_: any) {
error = error_;
}
expect(error.message).toBe(
'Expected array ["first","second"] (len 2) to be empty.',
);
expect(error.actual).toStrictEqual(["first", "second"]);
expect(error.expected).toBe("[]");
});

it("throws on null", () => {
expect(() => {
assertArrayEmpty(null);
}).toThrow("Expected null to be an empty array.");
});

it("throws on undefined", () => {
expect(() => {
assertArrayEmpty(undefined);
}).toThrow("Expected undefined to be an empty array.");
});

it("throws on non-arrays", () => {
expect(() => {
assertArrayEmpty("abc");
}).toThrow('Expected string "abc" to be an empty array.');
});

it("throws on an empty string, which is not an array", () => {
expect(() => {
assertArrayEmpty("");
}).toThrow('Expected string "" to be an empty array.');
});

it("uses a caller-supplied message", () => {
expect(() => {
assertArrayEmpty(["a"], "Every statement should have been evaluated.");
}).toThrow("Every statement should have been evaluated.");
});

it("preserves specific array type information when value is already an array", () => {
const value: ("foo" | "bar")[] = [];

assertArrayEmpty(value);

expectTypeOf(value).toEqualTypeOf<("foo" | "bar")[] & []>();
expectTypeOf(value).toExtend<("foo" | "bar")[]>();
expect(value).toBeTypeOf("object");
});

it("preserves readonly array type information when value is already readonly", () => {
const value: readonly ("foo" | "bar")[] = [];

assertArrayEmpty(value);

expectTypeOf(value).toEqualTypeOf<
readonly ("foo" | "bar")[] & readonly []
>();
expectTypeOf(value).toExtend<readonly ("foo" | "bar")[]>();
expect(value).toBeTypeOf("object");
});

it("narrows unknown values to an empty array", () => {
const value: unknown = [];

assertArrayEmpty(value);

expectTypeOf(value).toEqualTypeOf<[]>();
expect(value).toBeTypeOf("object");
});
});

describe("emptyArray", () => {
it("works as composable matcher", () => {
interface Report {
unevaluated?: { statement: string; reason: string }[];
}

function getReport(): Report {
return { unevaluated: [] };
}

const report = getReport();

assertObjectMatches(report, {
unevaluated: emptyArray(),
});

// Null-chain operator ? is not required after type narrowing.
// TypeScript knows report.unevaluated is an empty array.
expectTypeOf(report.unevaluated).toEqualTypeOf<[]>();
expect(report.unevaluated).toStrictEqual([]);
});

it("matches empty arrays", () => {
const matcher = emptyArray();

expect(matcher.isMatch([])).toBe(true);
});

it("does not match arrays with elements", () => {
const matcher = emptyArray();

expect(matcher.isMatch([1])).toBe(false);
expect(matcher.isMatch([1, 2, 3])).toBe(false);
});

it("does not match non-arrays", () => {
const matcher = emptyArray();

expect(matcher.isMatch(1)).toBe(false);
expect(matcher.isMatch("")).toBe(false);
expect(matcher.isMatch({ length: 0 })).toBe(false);
expect(matcher.isMatch(null)).toBe(false);
});

it("describes the emptyArray matcher", () => {
const matcher = emptyArray();

expect(desc(matcher)).toBe("empty array");
expect(repr(matcher)).toBe("[]");
});
});

it("uses an empty array type when the actual property is unknown", () => {
interface Foo {
bar?: unknown;
}

function getFoo(): Foo {
return { bar: [] };
}

const foo = getFoo();

assertObjectMatches(foo, {
bar: emptyArray(),
});

expectTypeOf(foo.bar).toEqualTypeOf<[]>();
expect(foo.bar).toStrictEqual([]);
});
});
52 changes: 52 additions & 0 deletions src/assert/array-empty/array-empty.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { AssertionMatcher, refinement } from "../../match/match.js";

/**
* Unique symbol to reliably identify the EmptyArrayMatcher type.
*
* TypeScript is structurally typed, so matcher types with similar predicates
* can otherwise accidentally satisfy each other's conditional branches.
*/
export const emptyArrayMatcher = Symbol("smartass.emptyArrayMatcher");

type ArrayBranchOf<TActual> = Extract<NonNullable<TActual>, readonly unknown[]>;

type ArrayMatchBranch<TActual> = [ArrayBranchOf<TActual>] extends [never]
? TActual
: ArrayBranchOf<TActual>;

type IsKnownReadonlyArray<TActual> =
NonNullable<TActual> extends readonly unknown[]
? NonNullable<TActual> extends unknown[]
? false
: true
: false;

export type EmptyArray = [];

export type ReadonlyEmptyArray = readonly [];

/**
* Type produced when an actual value is matched by emptyArray().
*
* An empty tuple has no element type to preserve, so the only thing carried
* over from the calling scope is whether the array is readonly.
*/
export type EmptyArrayMatch<TActual> =
IsKnownReadonlyArray<ArrayMatchBranch<TActual>> extends true
? ReadonlyEmptyArray
: EmptyArray;

export type EmptyArrayMatcher = AssertionMatcher<EmptyArray> & {
readonly [emptyArrayMatcher]: true;

/**
* Optional type-level hook used by compositional assertions such as
* assertObjectMatches().
*
* This lets the matcher describe how it refines an existing actual type,
* rather than only exposing the standalone matches() predicate type.
*/
readonly [refinement]?: <TActual>(
actual: TActual,
) => EmptyArrayMatch<TActual>;
};
8 changes: 7 additions & 1 deletion src/assert/object-matches/object-matches.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import type {
NonEmptyArrayMatch,
NonEmptyArrayMatcher,
} from "../array-not-empty/array-not-empty.type.js";
import type {
EmptyArrayMatch,
EmptyArrayMatcher,
} from "../array-empty/array-empty.type.js";
import type {
ArrayNotIncludingMatch,
ArrayNotIncludingMatcher,
Expand Down Expand Up @@ -203,7 +207,9 @@ type ArrayMatcherRefine<TActual, TExpected extends AssertionMatcher<unknown>> =
? ArrayOfMinLengthMatch<TActual, N>
: TExpected extends NonEmptyArrayMatcher
? NonEmptyArrayMatch<TActual>
: never;
: TExpected extends EmptyArrayMatcher
? EmptyArrayMatch<TActual>
: never;

/**
* Explicit refinement branches for string-specific matchers.
Expand Down
12 changes: 12 additions & 0 deletions src/eslint/smartass-eslint.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ describe("smartassPreferSpecificAssertions", () => {
]);
});

it("suggests assertArrayEmpty for a zero expected length", () => {
expect(lint("assertArrayLength(values, 0);")).toStrictEqual([
"Use assertArrayEmpty(value) instead of assertArrayLength(value, 0).",
]);
});

it("leaves a non-zero or non-literal expected length alone", () => {
expect(lint("assertArrayLength(values, 2);")).toStrictEqual([]);
expect(lint("assertArrayLength(values, expected);")).toStrictEqual([]);
expect(lint("assertStringLength(text, 0);")).toStrictEqual([]);
});

it("leaves assertions that are already specific alone", () => {
expect(lint("assertArrayNotEmpty(values);")).toStrictEqual([]);
expect(lint("assertIdentical(name, 'smartass');")).toStrictEqual([]);
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from "./assert/array-empty/array-empty.assert.js";
export * from "./assert/array-empty/array-empty.match.js";
export * from "./assert/array-equals/array-equals.assert.js";
export * from "./assert/array-includes-all/array-includes-all.assert.js";
export * from "./assert/array-includes-all/array-includes-all.match.js";
Expand Down
9 changes: 9 additions & 0 deletions src/lint/prefer-specific-assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ export const preferSpecificAssertionRules: readonly PreferSpecificAssertionRule[
message:
"Use assertUndefined(value) instead of assertIdentical(value, undefined).",
},
{
// The only selector that starts from a specific assertion. Both linters run without type
// information, but assertArrayLength has already committed to an array in its callee name,
// so the string/array ambiguity that blocks the `.length` selectors below does not arise.
selector:
"CallExpression[callee.name='assertArrayLength'] > Literal[value=type(number)][value=0]:nth-child(2)",
message:
"Use assertArrayEmpty(value) instead of assertArrayLength(value, 0).",
},
{
selector:
"CallExpression[callee.name='assertIdentical'] > UnaryExpression[operator='typeof']:first-child",
Expand Down
Loading
Loading