Skip to content

LastArrayElement: Fix handling of tuples with optional elements#1461

Open
chatman-media wants to merge 7 commits into
sindresorhus:mainfrom
chatman-media:fix/last-array-element-optional
Open

LastArrayElement: Fix handling of tuples with optional elements#1461
chatman-media wants to merge 7 commits into
sindresorhus:mainfrom
chatman-media:fix/last-array-element-optional

Conversation

@chatman-media

Copy link
Copy Markdown

Problem

LastArrayElement returns incorrect results for tuples that contain optional elements.

import type {LastArrayElement} from 'type-fest';

type A = LastArrayElement<[string, number?]>;
//=> string | number | undefined   ❌ (spurious `undefined`)

type B = LastArrayElement<[number?]>;
//=> number | undefined            ❌

type C = LastArrayElement<[string, boolean, number?]>;
//=> boolean | number | undefined  ❌

A tuple with a trailing optional element (e.g. [string, number?]) does not match either [...infer U, infer V] or [infer U, ...infer V], so it falls through to the ReadonlyArray<infer U> branch. Because the element type of an optional member is T | undefined, a spurious | undefined leaks into the result.

Fix

In the ReadonlyArray<infer U> branch, distinguish a genuine non-tuple array / trailing spread element (number extends Elements['length']) from a tuple made up solely of optional elements (finite length). For the latter, the | undefined introduced by the optional modifier is removed.

type A = LastArrayElement<[string, number?]>;            //=> string | number
type B = LastArrayElement<[number?]>;                    //=> number
type C = LastArrayElement<[string, boolean, number?]>;   //=> boolean | number
type D = LastArrayElement<[string, number?, boolean?]>;  //=> string | number | boolean

A genuine undefined-containing array is left untouched, so existing behavior is preserved:

type E = LastArrayElement<Array<number | undefined>>;    //=> number | undefined

Tests

Added cases to test-d/last-array-element.ts covering trailing/leading optional elements, the readonly variant, and a regression guard ensuring genuine undefined element types are preserved. All new tests fail on main and pass with the fix; the rest of the suite (tsd, tsc, xo) stays green.

Comment thread test-d/last-array-element.ts Outdated

// The `| undefined` from a genuine `undefined`-containing array is preserved
declare const undefinedArray: Array<number | undefined>;
expectType<number | undefined>(lastOf(undefinedArray));

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expectType<number | undefined>(lastOf(undefinedArray));
expectType<number | undefined>(lastOf(undefinedArray));
declare const optionalExplicitUndefinedTuple: [string, (number | undefined)?];
expectType<string | number | undefined>(lastOf(optionalExplicitUndefinedTuple));
declare const optionalExplicitUndefinedOnlyTuple: [(number | undefined)?];
expectType<number | undefined>(lastOf(optionalExplicitUndefinedOnlyTuple));
declare const optionalBeforeRestTuple: [string, number?, ...boolean[]];
expectType<string | number | boolean>(lastOf(optionalBeforeRestTuple));

@sindresorhus

Copy link
Copy Markdown
Owner

I think the fix needs to distinguish undefined from the optional modifier from explicit undefined in the element type.

For example, LastArrayElement<[string, (number | undefined)?]> should include undefined, but it currently gets removed. At the same time, LastArrayElement<[string, number?, ...boolean[]]> should not include undefined, but it currently does.

So Exclude<..., undefined> seems too broad here. The implementation likely needs to detect optional tuple elements more directly instead of stripping undefined from the whole element union.

@som-sm

som-sm commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Also, the presence of | undefined in the result should be according to the exactOptionalPropertyTypes compiler option, refer:

// @exactOptionalPropertyTypes: false
import type {LastArrayElement} from 'type-fest';

type T = LastArrayElement<[1, 2, 3?]>;
//=> 2 | 3 | undefined
// @exactOptionalPropertyTypes: true
import type {LastArrayElement} from 'type-fest';

type T = LastArrayElement<[1, 2, 3?]>;
//=> 2 | 3

Also, I think the result for empty tuples, non-tuple arrays (e.g., string[]), and tuples starting with an optional element (e.g., [string?, number?]) should all include | undefined, as mentioned in #647 (comment). This aligns with the runtime behavior of [].at(-1), which returns undefined, and [] is a valid value for both non-tuple arrays and tuples starting with an optional element.

@som-sm

som-sm commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Here's an implementation that addresses all the concerns mentioned above. It's built using our SplitOnRestElement type.

import type {If} from './if.d.ts';
import type {IsExactOptionalPropertyTypesEnabled} from './internal/type.d.ts';
import type {IsAny} from './is-any.d.ts';
import type {SplitOnRestElement} from './split-on-rest-element.d.ts';
import type {UnknownArray} from './unknown-array.d.ts';

export type LastArrayElement<TArray extends UnknownArray> =
	IsAny<TArray> extends true
		? any
		: TArray extends UnknownArray // For distributing `TArray`
			? SplitOnRestElement<TArray> extends readonly [infer BeforeRest extends UnknownArray, infer Rest extends UnknownArray, infer AfterRest extends UnknownArray]
				? _LastArrayElement<BeforeRest, Rest, AfterRest>
				: never
			: never;

type _LastArrayElement<BeforeRest extends UnknownArray, Rest extends UnknownArray, AfterRest extends UnknownArray> =
	AfterRest extends readonly [...any, infer Last] // Note there are no optional elements in `AfterRest`.
		? Last // If there's a `Last` in `AfterRest`, then that's the result.
		: Rest[number] | BeforeRestLastElement<BeforeRest>; // Otherwise, the result is union of the `Rest` element and the last element in `BeforeRest`.

type BeforeRestLastElement<BeforeRest extends UnknownArray, Accumulator = never> =
	BeforeRest extends readonly []
		? Accumulator | undefined
		: BeforeRest extends readonly [...any, infer Last]
			? Last | Accumulator
			: BeforeRest extends readonly [...infer Rest, (infer Last)?]
				? BeforeRestLastElement<
					Rest,
					// Add `undefined` for optional elements, if `exactOptionalPropertyTypes` is disabled.
					Last | Accumulator | If<IsExactOptionalPropertyTypesEnabled, never, undefined>
				>
				: never;

And, here's the updated test suite:

import {expectType} from 'tsd';
import type {LastArrayElement} from '../index.d.ts';

// Empty array
expectType<undefined>(undefined as LastArrayElement<[]>);
expectType<undefined>(undefined as LastArrayElement<readonly []>);

// Only required elements
expectType<boolean>({} as LastArrayElement<[number, string, boolean]>);
expectType<boolean>({} as LastArrayElement<readonly [number, string, boolean]>);

// Required and optional elements
expectType<2 | 3>({} as LastArrayElement<[1, 2, 3?]>);
expectType<1 | 2 | 3>({} as LastArrayElement<readonly [1, 2?, 3?]>);
expectType<number | boolean | bigint | string>(
	{} as LastArrayElement<[null, string, bigint?, boolean?, number?]>,
);

// Required and rest element
expectType<3 | string>({} as LastArrayElement<readonly [1, 2, 3, ...string[]]>); // Rest element at the end
expectType<'z'>({} as LastArrayElement<[1, 2, 3, ...boolean[], 'x', 'y', 'z']>); // Rest element in the middle
expectType<string>({} as LastArrayElement<readonly [...bigint[], string]>); // Rest element at the start

// Only optional elements
expectType<'a' | undefined>({} as LastArrayElement<['a'?]>);
expectType<{data: string[]} | undefined>({} as LastArrayElement<readonly [{data: string[]}?]>);
expectType<string | boolean | bigint | undefined>({} as LastArrayElement<[string?, boolean?, bigint?]>);

// Explicit `undefined` in optional elements
expectType<1 | 2 | undefined>({} as LastArrayElement<[0, 1, (2 | undefined)?]>);
expectType<1 | 2 | undefined | string>({} as LastArrayElement<readonly [0, 1, (2 | undefined)?, ...string[]]>);

// Required, optional, and rest element
expectType<2 | 3 | string>({} as LastArrayElement<[1, 2, 3?, ...string[]]>);
expectType<number | string | bigint>({} as LastArrayElement<readonly [number, string?, ...bigint[]]>);

// Optional and rest element
expectType<1 | string | undefined>({} as LastArrayElement<readonly [1?, ...string[]]>);
expectType<1 | 2 | 3 | bigint | undefined>({} as LastArrayElement<[1?, 2?, 3?, ...bigint[]]>);

// Labelled tuples
expectType<number>({} as LastArrayElement<[x: number, y: number]>);
expectType<string | number>({} as LastArrayElement<readonly [x: string, y?: number]>);
expectType<string | number>({} as LastArrayElement<[x: string, ...rest: number[]]>);

// Union elements
expectType<'d' | 'e'>({} as LastArrayElement<['a' | 'b', 'c', 'd' | 'e']>);
expectType<3 | 'c' | 'd' | 'e'>({} as LastArrayElement<[1 | 'a', 2 | 'b', 3 | 'c', ('d' | 'e')?]>);
expectType<boolean | string | number>({} as LastArrayElement<[bigint, boolean, ...Array<string | number>]>);

// Unions
expectType<3 | 6>({} as LastArrayElement<[1, 2, 3] | [4, 5, ...string[], 6]>);
expectType<number | bigint | undefined>({} as LastArrayElement<readonly [string, number] | bigint[]>);
expectType<1 | 2 | 3 | 'a' | 'b' | undefined | 'd' | 'e' | 'g'>(
	{} as LastArrayElement<readonly [1, 2?, 3?] | ['a'?, 'b'?] | ['c', 'd', ...Array<'e'>] | [...Array<'f'>, 'g']>,
);

// Non-tuple arrays
expectType<string | undefined>({} as LastArrayElement<string[]>);
expectType<number | undefined>({} as LastArrayElement<readonly number[]>);
expectType<number | bigint | undefined>({} as LastArrayElement<ReadonlyArray<number | bigint>>);
expectType<any | undefined>({} as LastArrayElement<any[]>);
expectType<undefined>({} as any as LastArrayElement<never[]>);

// Boundary cases
expectType<any>({} as LastArrayElement<any>);
expectType<never>({} as LastArrayElement<never>);

@chatman-media

Copy link
Copy Markdown
Author

Thanks @som-sm — applied your SplitOnRestElement-based implementation, which handles all the cases cleanly. It now correctly distinguishes the optional modifier from explicit undefined in the element type and respects exactOptionalPropertyTypes:

  • LastArrayElement<[string, (number | undefined)?]>string | number | undefined (explicit undefined preserved)
  • LastArrayElement<[string, number?, ...boolean[]]>string | number | boolean (no spurious undefined)
  • LastArrayElement<[1, 2, 3?]>2 | 3 under the repo's exactOptionalPropertyTypes: true
  • empty tuples, all-optional tuples, and non-tuple arrays all include | undefined, matching [].at(-1)

I kept the existing JSDoc block and used your full test suite as-is. npm test (tsc + tsd + xo + linter) is green with no regressions. Credit for the implementation and tests goes to you.

@som-sm

som-sm commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Tests should pass once #1462 is merged.

Revert the `IfNotAnyOrNever` wrapper back to the inline `IsAny<TArray> extends true ? any : ...` form from the proposed implementation. The wrapper eagerly instantiates the recursive `SplitOnRestElement` body for `any`, producing "Type instantiation is excessively deep" (only resolved once sindresorhus#1462 changes `IfNotAnyOrNever` to lazily evaluate its branches). The inline short-circuit keeps the body in a non-instantiated branch, so `LastArrayElement<any>` resolves to `any` and the whole suite passes on `main` today without depending on sindresorhus#1462.
@chatman-media

Copy link
Copy Markdown
Author

@som-sm I kept your SplitOnRestElement-based implementation and full test suite as-is — they're spot on.

The only thing blocking the suite was the IfNotAnyOrNever wrapper I'd added on top of your snippet in a follow-up commit: with the current IfNotAnyOrNever it eagerly instantiates the recursive SplitOnRestElement body for any, which trips "Type instantiation is excessively deep" on LastArrayElement<any> (exactly what #1462 fixes by evaluating the branches lazily). Your original snippet didn't have that problem because the inline IsAny<TArray> extends true ? any : … short-circuit leaves the body in a non-instantiated branch.

So I reverted that wrapper back to your inline IsAny form (last commit). With that, the whole suite — tsc, tsd, xo, linter — is green on main today, no longer waiting on #1462.

That said, if you'd rather keep this consistent with ArrayTail and the other IfNotAnyOrNever call sites, I'm happy to switch it back to IfNotAnyOrNever (object-cases form) once #1462 lands — I've verified the suite passes that way too. Just let me know which you prefer. Credit for the implementation and tests is yours either way.

@som-sm

som-sm commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Wrapping it in IfNotAnyOrNever was an intentional change as already mentioned in #1461 (comment). This PR will get merged after #1462, so please revert your change.

@som-sm

som-sm commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Updated overview of the PR:

  • Complete rewrite of the implementation and the tests.

  • Fixes the following cases:

    // @exactOptionalPropertyTypes: true
    
    type T1 = LastArrayElement<[bigint, string, number?]>;
    - //=> string | number | undefined
    + //=> string | number
    
    type T2 = LastArrayElement<[]>;
    - //=> never
    + //=> undefined
    
    type T3 = LastArrayElement<string[]>;
    - //=> string
    + //=> string | undefined

    I think it's fine to not consider the [] and string[] cases a breaking change because this type is meant to match runtime behaviour considering how it works with optional elements.

@som-sm som-sm requested a review from sindresorhus June 23, 2026 07:27
@chatman-media

Copy link
Copy Markdown
Author

Thanks @som-sm — I see you've already switched it back to IfNotAnyOrNever (object-cases form) and pulled #1462 into the branch now that it's merged, which is exactly what was blocking the LastArrayElement<any> "excessively deep" path before. That resolves my earlier note, so disregard the inline-IsAny detour I'd taken — your form is the right one and keeps this consistent with ArrayTail and the other call sites.

I re-ran the full suite on the current branch head and it's green: tsc and tsd both pass under the repo's exactOptionalPropertyTypes: true, including all of your rewritten test-d/last-array-element.ts assertions (empty/all-optional → | undefined, optional-modifier vs explicit undefined, rest-element positions, unions, labelled tuples).

Agreed on the updated overview: treating LastArrayElement<[]>undefined and LastArrayElement<string[]>string | undefined as non-breaking is the right call given the type is meant to mirror [].at(-1) and the optional-element semantics. The implementation and tests are yours — happy to leave the wheel with you for the merge-after-#1462 landing. Nice work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants