From 7e475aae98169a8ff5086e1a8db7362cc8b10dcf Mon Sep 17 00:00:00 2001 From: Hugh Grigg Date: Wed, 8 Jul 2026 21:09:10 +0100 Subject: [PATCH] feat: array-length array-min-length incorporate union types --- .../array-length/array-length.assert.ts | 8 +- src/assert/array-length/array-length.test.ts | 72 ++++++++++++++++++ src/assert/array-length/array-length.type.ts | 14 ++-- .../array-min-length.assert.ts | 8 +- .../array-min-length/array-min-length.test.ts | 73 +++++++++++++++++++ .../array-min-length/array-min-length.type.ts | 14 ++-- 6 files changed, 171 insertions(+), 18 deletions(-) diff --git a/src/assert/array-length/array-length.assert.ts b/src/assert/array-length/array-length.assert.ts index 47f3011..522a47d 100644 --- a/src/assert/array-length/array-length.assert.ts +++ b/src/assert/array-length/array-length.assert.ts @@ -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, readonly unknown[]> & - ArrayOfLengthMatch; +): asserts value is Extract, readonly unknown[]> & + ArrayOfLengthMatch, readonly unknown[]>, N>; export function assertArrayLength( value: unknown, diff --git a/src/assert/array-length/array-length.test.ts b/src/assert/array-length/array-length.test.ts index f8335af..5032a89 100644 --- a/src/assert/array-length/array-length.test.ts +++ b/src/assert/array-length/array-length.test.ts @@ -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(); + 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"]; @@ -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(); + 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); diff --git a/src/assert/array-length/array-length.type.ts b/src/assert/array-length/array-length.type.ts index 2369c83..5b869b4 100644 --- a/src/assert/array-length/array-length.type.ts +++ b/src/assert/array-length/array-length.type.ts @@ -12,11 +12,15 @@ type ArrayElementOf = T extends readonly (infer TElement)[] ? TElement : unknown; -type ArrayMatchElement = [ - Extract, readonly unknown[]>, -] extends [never] +type ArrayBranchOf = Extract, readonly unknown[]>; + +type ArrayMatchElement = [ArrayBranchOf] extends [never] ? unknown - : ArrayElementOf, readonly unknown[]>>; + : ArrayElementOf>; + +type ArrayMatchBranch = [ArrayBranchOf] extends [never] + ? TActual + : ArrayBranchOf; export type ArrayOfLength = N extends 0 ? [] @@ -56,7 +60,7 @@ type IsKnownReadonlyArray = : false; type ArrayOfLengthMatchForMutability = - IsKnownReadonlyArray extends true + IsKnownReadonlyArray> extends true ? ReadonlyArrayOfLength : ArrayOfLength; diff --git a/src/assert/array-min-length/array-min-length.assert.ts b/src/assert/array-min-length/array-min-length.assert.ts index 4f054c5..203bb41 100644 --- a/src/assert/array-min-length/array-min-length.assert.ts +++ b/src/assert/array-min-length/array-min-length.assert.ts @@ -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, readonly unknown[]> & - ArrayOfMinLengthMatch; +): asserts value is Extract, readonly unknown[]> & + ArrayOfMinLengthMatch, readonly unknown[]>, N>; export function assertArrayMinLength( value: unknown, diff --git a/src/assert/array-min-length/array-min-length.test.ts b/src/assert/array-min-length/array-min-length.test.ts index be94ff6..9dc54fb 100644 --- a/src/assert/array-min-length/array-min-length.test.ts +++ b/src/assert/array-min-length/array-min-length.test.ts @@ -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(); + 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"]; @@ -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(); + 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); diff --git a/src/assert/array-min-length/array-min-length.type.ts b/src/assert/array-min-length/array-min-length.type.ts index cc9266e..433965b 100644 --- a/src/assert/array-min-length/array-min-length.type.ts +++ b/src/assert/array-min-length/array-min-length.type.ts @@ -14,11 +14,15 @@ type ArrayElementOf = T extends readonly (infer TElement)[] ? TElement : unknown; -type ArrayMatchElement = [ - Extract, readonly unknown[]>, -] extends [never] +type ArrayBranchOf = Extract, readonly unknown[]>; + +type ArrayMatchElement = [ArrayBranchOf] extends [never] ? unknown - : ArrayElementOf, readonly unknown[]>>; + : ArrayElementOf>; + +type ArrayMatchBranch = [ArrayBranchOf] extends [never] + ? TActual + : ArrayBranchOf; export type ArrayOfMinLength = N extends 0 ? T[] @@ -46,7 +50,7 @@ type IsKnownReadonlyArray = : false; type ArrayOfMinLengthMatchForMutability = - IsKnownReadonlyArray extends true + IsKnownReadonlyArray> extends true ? ReadonlyArrayOfMinLength : ArrayOfMinLength;