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
8 changes: 4 additions & 4 deletions src/assert/array-length/array-length.assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { desc, repr } from "../../describe/describe.js";
import type { ArrayOfLength, ArrayOfLengthMatch } from "./array-length.type.js";

export function assertArrayLength<
TArray extends readonly unknown[] | null | undefined,
TActual extends object | null | undefined,
const N extends number,
>(
value: TArray,
value: TActual,
expectedLength: N,
message?: string,
): asserts value is Extract<NonNullable<TArray>, readonly unknown[]> &
ArrayOfLengthMatch<TArray, N>;
): asserts value is Extract<NonNullable<TActual>, readonly unknown[]> &
ArrayOfLengthMatch<Extract<NonNullable<TActual>, readonly unknown[]>, N>;

export function assertArrayLength<const N extends number>(
value: unknown,
Expand Down
72 changes: 72 additions & 0 deletions src/assert/array-length/array-length.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,41 @@ describe("array-length", () => {
expect(container.items[1].enabled).toBe(false);
});

it("narrows union of non-array and readonly array to the readonly array branch", () => {
interface PolicyStatement {
readonly Effect: "Allow" | "Deny";
readonly Action: string;
readonly Resource: string;
}

interface PolicyDocument {
readonly Version?: string | undefined;
readonly Statement: PolicyStatement | readonly PolicyStatement[];
}

const document: PolicyDocument = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::example-bucket/*",
},
],
};

assertArrayLength(document.Statement, 1);

expectTypeOf(document.Statement).toEqualTypeOf<
readonly PolicyStatement[] & readonly [PolicyStatement]
>();
expectTypeOf(document.Statement[0]).toEqualTypeOf<PolicyStatement>();
expectTypeOf(document.Statement[0].Effect).toEqualTypeOf<
"Allow" | "Deny"
>();
expect(document.Statement[0].Effect).toBe("Allow");
});

it("narrows unknown values to an array of the expected length", () => {
const value: unknown = ["foo", "bar"];

Expand Down Expand Up @@ -276,6 +311,43 @@ describe("array-length", () => {
expect(container.items[1].enabled).toBe(false);
});

it("narrows union of non-array and readonly array when used as a matcher", () => {
interface PolicyStatement {
readonly Effect: "Allow" | "Deny";
readonly Action: string;
readonly Resource: string;
}

interface PolicyDocument {
readonly Version?: string | undefined;
readonly Statement: PolicyStatement | readonly PolicyStatement[];
}

const document: PolicyDocument = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::example-bucket/*",
},
],
};

assertObjectMatches(document, {
Statement: arrayOfLength(1),
});

expectTypeOf(document.Statement).toEqualTypeOf<
readonly [PolicyStatement]
>();
expectTypeOf(document.Statement[0]).toEqualTypeOf<PolicyStatement>();
expectTypeOf(document.Statement[0].Effect).toEqualTypeOf<
"Allow" | "Deny"
>();
expect(document.Statement[0].Effect).toBe("Allow");
});

it("matches arrays with the expected length", () => {
const matcher = arrayOfLength(3);

Expand Down
14 changes: 9 additions & 5 deletions src/assert/array-length/array-length.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ type ArrayElementOf<T> = T extends readonly (infer TElement)[]
? TElement
: unknown;

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

type ArrayMatchElement<TActual> = [ArrayBranchOf<TActual>] extends [never]
? unknown
: ArrayElementOf<Extract<NonNullable<TActual>, readonly unknown[]>>;
: ArrayElementOf<ArrayBranchOf<TActual>>;

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

export type ArrayOfLength<T, N extends number> = N extends 0
? []
Expand Down Expand Up @@ -56,7 +60,7 @@ type IsKnownReadonlyArray<TActual> =
: false;

type ArrayOfLengthMatchForMutability<TActual, TElement, N extends number> =
IsKnownReadonlyArray<TActual> extends true
IsKnownReadonlyArray<ArrayMatchBranch<TActual>> extends true
? ReadonlyArrayOfLength<TElement, N>
: ArrayOfLength<TElement, N>;

Expand Down
8 changes: 4 additions & 4 deletions src/assert/array-min-length/array-min-length.assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import type {
} from "./array-min-length.type.js";

export function assertArrayMinLength<
TArray extends readonly unknown[] | null | undefined,
TActual extends object | null | undefined,
const N extends number,
>(
value: TArray,
value: TActual,
minLength: N,
message?: string,
): asserts value is Extract<NonNullable<TArray>, readonly unknown[]> &
ArrayOfMinLengthMatch<TArray, N>;
): asserts value is Extract<NonNullable<TActual>, readonly unknown[]> &
ArrayOfMinLengthMatch<Extract<NonNullable<TActual>, readonly unknown[]>, N>;

export function assertArrayMinLength<const N extends number>(
value: unknown,
Expand Down
73 changes: 73 additions & 0 deletions src/assert/array-min-length/array-min-length.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,42 @@ describe("array-min-length", () => {
expect(container.items[1].enabled).toBe(false);
});

it("narrows union of non-array and readonly array to the readonly array branch", () => {
interface PolicyStatement {
readonly Effect: "Allow" | "Deny";
readonly Action: string;
readonly Resource: string;
}

interface PolicyDocument {
readonly Version?: string | undefined;
readonly Statement: PolicyStatement | readonly PolicyStatement[];
}

const document: PolicyDocument = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::example-bucket/*",
},
],
};

assertArrayMinLength(document.Statement, 1);

expectTypeOf(document.Statement).toEqualTypeOf<
readonly PolicyStatement[] &
readonly [PolicyStatement, ...PolicyStatement[]]
>();
expectTypeOf(document.Statement[0]).toEqualTypeOf<PolicyStatement>();
expectTypeOf(document.Statement[0].Effect).toEqualTypeOf<
"Allow" | "Deny"
>();
expect(document.Statement[0].Effect).toBe("Allow");
});

it("narrows unknown values to an array with at least the expected length", () => {
const value: unknown = ["foo", "bar"];

Expand Down Expand Up @@ -282,6 +318,43 @@ describe("array-min-length", () => {
expect(container.items[1].enabled).toBe(false);
});

it("narrows union of non-array and readonly array when used as a matcher", () => {
interface PolicyStatement {
readonly Effect: "Allow" | "Deny";
readonly Action: string;
readonly Resource: string;
}

interface PolicyDocument {
readonly Version?: string | undefined;
readonly Statement: PolicyStatement | readonly PolicyStatement[];
}

const document: PolicyDocument = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: "s3:GetObject",
Resource: "arn:aws:s3:::example-bucket/*",
},
],
};

assertObjectMatches(document, {
Statement: arrayOfMinLength(1),
});

expectTypeOf(document.Statement).toEqualTypeOf<
readonly [PolicyStatement, ...PolicyStatement[]]
>();
expectTypeOf(document.Statement[0]).toEqualTypeOf<PolicyStatement>();
expectTypeOf(document.Statement[0].Effect).toEqualTypeOf<
"Allow" | "Deny"
>();
expect(document.Statement[0].Effect).toBe("Allow");
});

it("matches arrays with exactly the minimum length", () => {
const matcher = arrayOfMinLength(3);

Expand Down
14 changes: 9 additions & 5 deletions src/assert/array-min-length/array-min-length.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ type ArrayElementOf<T> = T extends readonly (infer TElement)[]
? TElement
: unknown;

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

type ArrayMatchElement<TActual> = [ArrayBranchOf<TActual>] extends [never]
? unknown
: ArrayElementOf<Extract<NonNullable<TActual>, readonly unknown[]>>;
: ArrayElementOf<ArrayBranchOf<TActual>>;

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

export type ArrayOfMinLength<T, N extends number> = N extends 0
? T[]
Expand Down Expand Up @@ -46,7 +50,7 @@ type IsKnownReadonlyArray<TActual> =
: false;

type ArrayOfMinLengthMatchForMutability<TActual, TElement, N extends number> =
IsKnownReadonlyArray<TActual> extends true
IsKnownReadonlyArray<ArrayMatchBranch<TActual>> extends true
? ReadonlyArrayOfMinLength<TElement, N>
: ArrayOfMinLength<TElement, N>;

Expand Down