diff --git a/packages/dds/tree/src/core/rebase/utils.ts b/packages/dds/tree/src/core/rebase/utils.ts index c346e8a5ae1..e1463079409 100644 --- a/packages/dds/tree/src/core/rebase/utils.ts +++ b/packages/dds/tree/src/core/rebase/utils.ts @@ -5,7 +5,7 @@ import { assert, oob } from "@fluidframework/core-utils/internal"; -import type { Mutable } from "../../util/index.js"; +import { hasSome, type Mutable } from "../../util/index.js"; import { type ChangeRebaser, @@ -248,13 +248,15 @@ export function rebaseBranch( // If the source and target rebase path begin with a range that has all the same revisions, remove it; it is // equivalent on both branches and doesn't need to be rebased. const targetRebasePath = [...targetCommits]; - const minLength = Math.min(sourcePath.length, targetRebasePath.length); - for (let i = 0; i < minLength; i++) { - const firstSourcePath = sourcePath[0] ?? oob(); - const firstTargetRebasePath = targetRebasePath[0] ?? oob(); - if (firstSourcePath.revision === firstTargetRebasePath.revision) { - sourcePath.shift(); - targetRebasePath.shift(); + if (hasSome(sourcePath) && hasSome(targetRebasePath)) { + const minLength = Math.min(sourcePath.length, targetRebasePath.length); + for (let i = 0; i < minLength; i++) { + const firstSourcePath = sourcePath[0]; + const firstTargetRebasePath = targetRebasePath[0]; + if (firstSourcePath.revision === firstTargetRebasePath.revision) { + sourcePath.shift(); + targetRebasePath.shift(); + } } } @@ -264,7 +266,7 @@ export function rebaseBranch( // are in the same order, and have no other commits interleaving them, then no rebasing needs to occur. Those commits can // simply be removed from the source branch, and the remaining commits on the source branch are reparented off of the new // base commit. - if (targetRebasePath.length === 0) { + if (!hasSome(targetRebasePath)) { for (const c of sourcePath) { sourceCommits.push(mintCommit(sourceCommits[sourceCommits.length - 1] ?? newBase, c)); } diff --git a/packages/dds/tree/src/core/tree/detachedFieldIndexCodec.ts b/packages/dds/tree/src/core/tree/detachedFieldIndexCodec.ts index 2bbdc3dfae1..1f145dd76d4 100644 --- a/packages/dds/tree/src/core/tree/detachedFieldIndexCodec.ts +++ b/packages/dds/tree/src/core/tree/detachedFieldIndexCodec.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { assert, oob } from "@fluidframework/core-utils/internal"; +import { assert } from "@fluidframework/core-utils/internal"; import { type ICodecOptions, @@ -24,6 +24,7 @@ import type { Major, } from "./detachedFieldIndexTypes.js"; import type { IIdCompressor } from "@fluidframework/id-compressor"; +import { hasSingle } from "../../util/index.js"; class MajorCodec implements IJsonCodec { public constructor( @@ -83,8 +84,8 @@ export function makeDetachedNodeToFieldCodec( for (const [minor, detachedField] of innerMap) { rootRanges.push([minor, detachedField.root]); } - if (rootRanges.length === 1) { - const firstRootRange = rootRanges[0] ?? oob(); + if (hasSingle(rootRanges)) { + const firstRootRange = rootRanges[0]; const rootsForRevision: EncodedRootsForRevision = [ encodedRevision, firstRootRange[0], diff --git a/packages/dds/tree/src/feature-libraries/chunked-forest/chunkedForest.ts b/packages/dds/tree/src/feature-libraries/chunked-forest/chunkedForest.ts index b48d30cb729..aa8a17e7b62 100644 --- a/packages/dds/tree/src/feature-libraries/chunked-forest/chunkedForest.ts +++ b/packages/dds/tree/src/feature-libraries/chunked-forest/chunkedForest.ts @@ -29,7 +29,14 @@ import { rootFieldKey, } from "../../core/index.js"; import { createEmitter, type Listenable } from "../../events/index.js"; -import { assertValidRange, brand, fail, getOrAddEmptyToMap } from "../../util/index.js"; +import { + assertValidRange, + brand, + fail, + getLast, + getOrAddEmptyToMap, + hasSome, +} from "../../util/index.js"; import { BasicChunk, BasicChunkCursor, type SiblingsOrKey } from "./basicChunk.js"; import type { ChunkedCursor, TreeChunk } from "./chunk.js"; @@ -100,8 +107,8 @@ export class ChunkedForest implements IEditableForest { mutableChunkStack: [] as StackNode[], mutableChunk: this.roots as BasicChunk | undefined, getParent(): StackNode { - assert(this.mutableChunkStack.length > 0, 0x532 /* invalid access to root's parent */); - return this.mutableChunkStack[this.mutableChunkStack.length - 1] ?? oob(); + assert(hasSome(this.mutableChunkStack), 0x532 /* invalid access to root's parent */); + return getLast(this.mutableChunkStack); }, free(): void { this.mutableChunk = undefined; diff --git a/packages/dds/tree/src/feature-libraries/chunked-forest/uniformChunk.ts b/packages/dds/tree/src/feature-libraries/chunked-forest/uniformChunk.ts index 92871f119d5..01deb2dacd8 100644 --- a/packages/dds/tree/src/feature-libraries/chunked-forest/uniformChunk.ts +++ b/packages/dds/tree/src/feature-libraries/chunked-forest/uniformChunk.ts @@ -15,7 +15,7 @@ import { type UpPath, type Value, } from "../../core/index.js"; -import { ReferenceCountedBase, fail } from "../../util/index.js"; +import { ReferenceCountedBase, fail, hasSome } from "../../util/index.js"; import { SynchronousCursor, prefixFieldPath, prefixPath } from "../treeCursorUtils.js"; import { type ChunkedCursor, type TreeChunk, cursorChunk, dummyRoot } from "./chunk.js"; @@ -517,12 +517,12 @@ class Cursor extends SynchronousCursor implements ChunkedCursor { public firstField(): boolean { const fieldsArray = this.nodeInfo(CursorLocationType.Nodes).shape.fieldsArray; - if (fieldsArray.length === 0) { + if (!hasSome(fieldsArray)) { return false; } this.indexOfField = 0; this.mode = CursorLocationType.Fields; - const fields = fieldsArray[0] ?? oob(); + const fields = fieldsArray[0]; this.fieldKey = fields[0]; return true; } diff --git a/packages/dds/tree/src/feature-libraries/sequence-field/invert.ts b/packages/dds/tree/src/feature-libraries/sequence-field/invert.ts index 46c57436279..4a3247d9647 100644 --- a/packages/dds/tree/src/feature-libraries/sequence-field/invert.ts +++ b/packages/dds/tree/src/feature-libraries/sequence-field/invert.ts @@ -3,10 +3,10 @@ * Licensed under the MIT License. */ -import { assert, unreachableCase, oob } from "@fluidframework/core-utils/internal"; +import { assert, unreachableCase } from "@fluidframework/core-utils/internal"; import type { RevisionTag } from "../../core/index.js"; -import { type IdAllocator, type Mutable, fail } from "../../util/index.js"; +import { type IdAllocator, type Mutable, fail, hasSingle } from "../../util/index.js"; import { type CrossFieldManager, CrossFieldTarget, @@ -257,11 +257,11 @@ function invertMark( } assert( - detachInverses.length === 1, + hasSingle(detachInverses), 0x80d /* Only expected MoveIn marks to be split when inverting */, ); - let detachInverse = detachInverses[0] ?? oob(); + let detachInverse = detachInverses[0]; assert(isAttach(detachInverse), 0x80e /* Inverse of a detach should be an attach */); const inverses: Mark[] = []; diff --git a/packages/dds/tree/src/feature-libraries/sequence-field/sequenceFieldToDelta.ts b/packages/dds/tree/src/feature-libraries/sequence-field/sequenceFieldToDelta.ts index 9b915c3bbeb..4198ed09208 100644 --- a/packages/dds/tree/src/feature-libraries/sequence-field/sequenceFieldToDelta.ts +++ b/packages/dds/tree/src/feature-libraries/sequence-field/sequenceFieldToDelta.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { assert, unreachableCase, oob } from "@fluidframework/core-utils/internal"; +import { assert, unreachableCase } from "@fluidframework/core-utils/internal"; import { type DeltaDetachedNodeChanges, @@ -12,7 +12,7 @@ import { type DeltaMark, areEqualChangeAtomIds, } from "../../core/index.js"; -import type { Mutable } from "../../util/index.js"; +import { getLast, hasSome, type Mutable } from "../../util/index.js"; import { nodeIdFromChangeAtom } from "../deltaUtils.js"; import { isMoveIn, isMoveOut } from "./moveEffectTable.js"; @@ -176,8 +176,8 @@ export function sequenceFieldToDelta( } } // Remove trailing no-op marks - while (local.length > 0) { - const lastMark = local[local.length - 1] ?? oob(); + while (hasSome(local)) { + const lastMark = getLast(local); if ( lastMark.attach !== undefined || lastMark.detach !== undefined || diff --git a/packages/dds/tree/src/shared-tree-core/branch.ts b/packages/dds/tree/src/shared-tree-core/branch.ts index aaac692a3a8..1ae01ddc481 100644 --- a/packages/dds/tree/src/shared-tree-core/branch.ts +++ b/packages/dds/tree/src/shared-tree-core/branch.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { assert, oob } from "@fluidframework/core-utils/internal"; +import { assert } from "@fluidframework/core-utils/internal"; import { type TelemetryEventBatcher, measure } from "@fluidframework/telemetry-utils/internal"; import { @@ -24,7 +24,7 @@ import { import { createEmitter, type Listenable } from "../events/index.js"; import { TransactionStack } from "./transactionStack.js"; -import { fail } from "../util/index.js"; +import { fail, getLast, hasSome } from "../util/index.js"; /** * Describes a change to a `SharedTreeBranch`. Various operations can mutate the head of the branch; @@ -46,12 +46,12 @@ export type SharedTreeBranchChange = type: "append"; kind: CommitKind; change: TaggedChange; - newCommits: readonly GraphCommit[]; + newCommits: readonly [GraphCommit, ...GraphCommit[]]; } | { type: "remove"; change: TaggedChange | undefined; - removedCommits: readonly GraphCommit[]; + removedCommits: readonly [GraphCommit, ...GraphCommit[]]; } | { type: "replace"; @@ -314,7 +314,7 @@ export class SharedTreeBranch { this.editor.exitTransaction(); this.#events.emit("transactionCommitted", this.transactions.size === 0); - if (commits.length === 0) { + if (!hasSome(commits)) { return undefined; } @@ -355,7 +355,7 @@ export class SharedTreeBranch { this.editor.exitTransaction(); this.#events.emit("transactionAborted", this.transactions.size === 0); - if (commits.length === 0) { + if (!hasSome(commits)) { this.#events.emit("transactionRolledBack", this.transactions.size === 0); return [undefined, []]; } @@ -399,8 +399,13 @@ export class SharedTreeBranch { private popTransaction(): [GraphCommit, GraphCommit[]] { const { startRevision: startRevisionOriginal } = this.transactions.pop(); let startRevision = startRevisionOriginal; - while (this.initialTransactionRevToRebasedRev.has(startRevision)) { - startRevision = this.initialTransactionRevToRebasedRev.get(startRevision) ?? oob(); + + for ( + let r: RevisionTag | undefined = startRevision; + r !== undefined; + r = this.initialTransactionRevToRebasedRev.get(startRevision) + ) { + startRevision = r; } if (!this.isTransacting()) { @@ -460,13 +465,13 @@ export class SharedTreeBranch { // The net change to this branch is provided by the `rebaseBranch` API const { newSourceHead, commits } = rebaseResult; const { deletedSourceCommits, targetCommits, sourceCommits } = commits; + assert(hasSome(targetCommits), "Expected commit(s) for a non no-op rebase"); const newCommits = targetCommits.concat(sourceCommits); + if (this.isTransacting()) { - const firstCommit = targetCommits[0] ?? oob(); - const lastCommit = targetCommits[targetCommits.length - 1] ?? oob(); - const src = firstCommit.parent?.revision; - const dst = lastCommit.revision; + const src = targetCommits[0].parent?.revision; + const dst = getLast(targetCommits).revision; if (src !== undefined && dst !== undefined) { this.initialTransactionRevToRebasedRev.set(src, dst); } @@ -516,6 +521,7 @@ export class SharedTreeBranch { // Compute the net change to this branch const sourceCommits = rebaseResult.commits.sourceCommits; + assert(hasSome(sourceCommits), "Expected source commits in non no-op merge"); const change = this.changeFamily.rebaser.compose(sourceCommits); const taggedChange = makeAnonChange(change); const changeEvent = { diff --git a/packages/dds/tree/src/shared-tree-core/defaultResubmitMachine.ts b/packages/dds/tree/src/shared-tree-core/defaultResubmitMachine.ts index f516aa8754c..5e48d33d64a 100644 --- a/packages/dds/tree/src/shared-tree-core/defaultResubmitMachine.ts +++ b/packages/dds/tree/src/shared-tree-core/defaultResubmitMachine.ts @@ -5,7 +5,7 @@ import { assert, oob } from "@fluidframework/core-utils/internal"; import type { GraphCommit, TaggedChange } from "../core/index.js"; -import { disposeSymbol } from "../util/index.js"; +import { disposeSymbol, hasSome } from "../util/index.js"; import type { ChangeEnricherReadonlyCheckout, ResubmitMachine } from "./index.js"; /** @@ -114,7 +114,8 @@ export class DefaultResubmitMachine implements ResubmitMachine this.isInResubmitPhase, 0x982 /* No available commit to resubmit outside of resubmit phase */, ); - return this.resubmitQueue[0] ?? oob(); + assert(hasSome(this.resubmitQueue), "Expected resubmit queue to be non-empty"); + return this.resubmitQueue[0]; } public get isInResubmitPhase(): boolean { diff --git a/packages/dds/tree/src/shared-tree/sharedTreeChangeFamily.ts b/packages/dds/tree/src/shared-tree/sharedTreeChangeFamily.ts index 10f943254db..0a8a6fb8293 100644 --- a/packages/dds/tree/src/shared-tree/sharedTreeChangeFamily.ts +++ b/packages/dds/tree/src/shared-tree/sharedTreeChangeFamily.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { assert, oob } from "@fluidframework/core-utils/internal"; +import { assert } from "@fluidframework/core-utils/internal"; import type { ICodecFamily, ICodecOptions } from "../codec/index.js"; import { @@ -32,6 +32,7 @@ import { type NestedSet, addToNestedSet, fail, + hasSingle, nestedSetContains, } from "../util/index.js"; @@ -179,12 +180,12 @@ export class SharedTreeChangeFamily return SharedTreeChangeFamily.emptyChange; } assert( - change.change.changes.length === 1 && over.change.changes.length === 1, + hasSingle(change.change.changes) && hasSingle(over.change.changes), 0x884 /* SharedTreeChange should have exactly one inner change if no schema change is present. */, ); - const dataChangeIntention = change.change.changes[0] ?? oob(); - const dataChangeOver = over.change.changes[0] ?? oob(); + const dataChangeIntention = change.change.changes[0]; + const dataChangeOver = over.change.changes[0]; assert( dataChangeIntention.type === "data" && dataChangeOver.type === "data", 0x885 /* Data change should be present. */, diff --git a/packages/dds/tree/src/shared-tree/treeCheckout.ts b/packages/dds/tree/src/shared-tree/treeCheckout.ts index 5bb1fefafbf..5088b8d2225 100644 --- a/packages/dds/tree/src/shared-tree/treeCheckout.ts +++ b/packages/dds/tree/src/shared-tree/treeCheckout.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { assert, oob } from "@fluidframework/core-utils/internal"; +import { assert } from "@fluidframework/core-utils/internal"; import type { IIdCompressor } from "@fluidframework/id-compressor"; import { UsageError, @@ -59,7 +59,15 @@ import { getChangeReplaceType, type SharedTreeBranchChange, } from "../shared-tree-core/index.js"; -import { Breakable, TransactionResult, disposeSymbol, fail } from "../util/index.js"; +import { + Breakable, + TransactionResult, + disposeSymbol, + fail, + getLast, + hasSingle, + hasSome, +} from "../util/index.js"; import { SharedTreeChangeFamily, hasSchemaChange } from "./sharedTreeChangeFamily.js"; import type { SharedTreeChange } from "./sharedTreeChangeTypes.js"; @@ -468,12 +476,13 @@ export class TreeCheckout implements ITreeCheckoutFork { // One important consequence of this is that we will not submit the op containing the invalid change, since op submissions happens in response to `afterChange`. _branch.events.on("beforeChange", (event) => { if (event.change !== undefined) { - const revision = - event.type === "replace" - ? // Change events will always contain new commits - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - event.newCommits[event.newCommits.length - 1]!.revision - : event.change.revision; + let revision: RevisionTag | undefined; + if (event.type === "replace") { + assert(hasSome(event.newCommits), "Expected new commit for non no-op change event"); + revision = getLast(event.newCommits).revision; + } else { + revision = event.change.revision; + } // Conflicts due to schema will be empty and thus are not applied. for (const change of event.change.change.changes) { @@ -506,7 +515,11 @@ export class TreeCheckout implements ITreeCheckoutFork { this.events.emit("afterBatch"); } if (event.type === "replace" && getChangeReplaceType(event) === "transactionCommit") { - const firstCommit = event.newCommits[0] ?? oob(); + assert( + hasSingle(event.newCommits), + "Expected exactly one new commit for transaction commit event", + ); + const firstCommit = event.newCommits[0]; const transactionRevision = firstCommit.revision; for (const transactionStep of event.removedCommits) { this.removedRoots.updateMajor(transactionStep.revision, transactionRevision); diff --git a/packages/dds/tree/src/simple-tree/api/simpleSchemaToJsonSchema.ts b/packages/dds/tree/src/simple-tree/api/simpleSchemaToJsonSchema.ts index 0e81be8260d..5e2ebb69d57 100644 --- a/packages/dds/tree/src/simple-tree/api/simpleSchemaToJsonSchema.ts +++ b/packages/dds/tree/src/simple-tree/api/simpleSchemaToJsonSchema.ts @@ -3,10 +3,10 @@ * Licensed under the MIT License. */ -import { oob, unreachableCase } from "@fluidframework/core-utils/internal"; +import { unreachableCase } from "@fluidframework/core-utils/internal"; import { UsageError } from "@fluidframework/telemetry-utils/internal"; import { ValueSchema } from "../../core/index.js"; -import { getOrCreate, type Mutable } from "../../util/index.js"; +import { getOrCreate, hasSingle, type Mutable } from "../../util/index.js"; import type { JsonArrayNodeSchema, JsonFieldSchema, @@ -42,9 +42,9 @@ export function toJsonSchema(schema: SimpleTreeSchema): JsonTreeSchema { allowedTypes.push(createSchemaRef(allowedType)); } - return allowedTypes.length === 1 + return hasSingle(allowedTypes) ? { - ...(allowedTypes[0] ?? oob()), + ...allowedTypes[0], $defs: definitions, } : { @@ -96,8 +96,9 @@ function convertArrayNodeSchema(schema: SimpleArrayNodeSchema): JsonArrayNodeSch allowedTypes.push(createSchemaRef(type)); }); - const items: JsonFieldSchema = - allowedTypes.length === 1 ? (allowedTypes[0] ?? oob()) : { anyOf: allowedTypes }; + const items: JsonFieldSchema = hasSingle(allowedTypes) + ? allowedTypes[0] + : { anyOf: allowedTypes }; return { type: "array", @@ -142,12 +143,11 @@ function convertObjectNodeSchema(schema: SimpleObjectNodeSchema): JsonObjectNode allowedTypes.push(createSchemaRef(allowedType)); } - const output: Mutable = - allowedTypes.length === 1 - ? (allowedTypes[0] ?? oob()) - : { - anyOf: allowedTypes, - }; + const output: Mutable = hasSingle(allowedTypes) + ? allowedTypes[0] + : { + anyOf: allowedTypes, + }; // Don't include "description" property at all if it's not present in the input. if (value.metadata?.description !== undefined) { @@ -178,12 +178,11 @@ function convertMapNodeSchema(schema: SimpleMapNodeSchema): JsonMapNodeSchema { type: "object", _treeNodeSchemaKind: NodeKind.Map, patternProperties: { - "^.*$": - allowedTypes.length === 1 - ? (allowedTypes[0] ?? oob()) - : { - anyOf: allowedTypes, - }, + "^.*$": hasSingle(allowedTypes) + ? allowedTypes[0] + : { + anyOf: allowedTypes, + }, }, }; } diff --git a/packages/dds/tree/src/simple-tree/toMapTree.ts b/packages/dds/tree/src/simple-tree/toMapTree.ts index bd9767abc93..84ba65a2a63 100644 --- a/packages/dds/tree/src/simple-tree/toMapTree.ts +++ b/packages/dds/tree/src/simple-tree/toMapTree.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { assert, oob } from "@fluidframework/core-utils/internal"; +import { assert } from "@fluidframework/core-utils/internal"; import { UsageError } from "@fluidframework/telemetry-utils/internal"; import { isFluidHandle } from "@fluidframework/runtime-utils/internal"; @@ -21,7 +21,7 @@ import { valueSchemaAllows, type NodeKeyManager, } from "../feature-libraries/index.js"; -import { brand, fail, isReadonlyArray, find } from "../util/index.js"; +import { brand, fail, isReadonlyArray, find, hasSome, hasSingle } from "../util/index.js"; import { nullSchema } from "./leafNodeSchema.js"; import { @@ -465,10 +465,10 @@ function getType( ); } assert( - possibleTypes.length !== 0, + hasSome(possibleTypes), 0x84e /* data is incompatible with all types allowed by the schema */, ); - if (possibleTypes.length !== 1) { + if (!hasSingle(possibleTypes)) { throw new UsageError( `The provided data is compatible with more than one type allowed by the schema. The set of possible types is ${JSON.stringify([ @@ -478,7 +478,7 @@ Explicitly construct an unhydrated node of the desired type to disambiguate. For class-based schema, this can be done by replacing an expression like "{foo: 1}" with "new MySchema({foo: 1})".`, ); } - return possibleTypes[0] ?? oob(); + return possibleTypes[0]; } /** diff --git a/packages/dds/tree/src/util/index.ts b/packages/dds/tree/src/util/index.ts index 91398519836..897bc1841f1 100644 --- a/packages/dds/tree/src/util/index.ts +++ b/packages/dds/tree/src/util/index.ts @@ -91,6 +91,9 @@ export { compareStrings, find, count, + getLast, + hasSome, + hasSingle, } from "./utils.js"; export { ReferenceCountedBase, type ReferenceCounted } from "./referenceCounting.js"; diff --git a/packages/dds/tree/src/util/utils.ts b/packages/dds/tree/src/util/utils.ts index 09874b71d88..8fa6985d136 100644 --- a/packages/dds/tree/src/util/utils.ts +++ b/packages/dds/tree/src/util/utils.ts @@ -79,6 +79,44 @@ export function makeArray(size: number, filler: (index: number) => T): T[] { return array; } +/** + * Returns the last element of an array, or `undefined` if the array has no elements. + * @param array - The array to get the last element from. + * @remarks + * If the type of the array has been narrowed by e.g. {@link hasSome | hasSome(array)} or {@link hasSingle | hasOne(array)} then the return type will be `T` rather than `T | undefined`. + */ +export function getLast(array: readonly [T, ...T[]]): T; +export function getLast(array: { [index: number]: T; length: number }): T | undefined; +export function getLast(array: { [index: number]: T; length: number }): T | undefined { + return array[array.length - 1]; +} + +/** + * Returns true if and only if the given array has at least one element. + * @param array - The array to check. + * @remarks + * If `array` contains at least one element, its type will be narrowed and can benefit from improved typing from e.g. `array[0]` and {@link getLast | getLast(array)}. + * This is especially useful when "noUncheckedIndexedAccess" is enabled in the TypeScript compiler options, since the return type of `array[0]` will be `T` rather than `T | undefined`. + */ +export function hasSome(array: T[]): array is [T, ...T[]]; +export function hasSome(array: readonly T[]): array is readonly [T, ...T[]]; +export function hasSome(array: readonly T[]): array is [T, ...T[]] { + return array.length > 0; +} + +/** + * Returns true if and only if the given array has exactly one element. + * @param array - The array to check. + * @remarks + * If `array` contains exactly one element, its type will be narrowed and can benefit from improved typing from e.g. `array[0]` and {@link getLast | getLast(array)}. + * This is especially useful when "noUncheckedIndexedAccess" is enabled in the TypeScript compiler options, since the return type of `array[0]` will be `T` rather than `T | undefined`. + */ +export function hasSingle(array: T[]): array is [T]; +export function hasSingle(array: readonly T[]): array is readonly [T]; +export function hasSingle(array: readonly T[]): array is [T] { + return array.length === 1; +} + /** * Compares two sets using callbacks. * Early returns on first false comparison.