From d1dd996796cf4a4b22027f648a047b29dd518f12 Mon Sep 17 00:00:00 2001 From: KevinBigham Date: Mon, 11 May 2026 04:58:16 -0500 Subject: [PATCH] `NonEmptyObject`: Handle index signatures conservatively Addresses #821. A type whose only members are index signatures cannot statically express "at least one dynamic key" in TypeScript, so `NonEmptyObject` previously degenerated to the input type and silently accepted `{}`. Re-derive the key set via `OmitIndexSignature` so: - Pure-index-signature types fail closed and resolve to `never`. - Mixed types are driven off the explicit-key subset, so index signatures no longer mask `HasRequiredKeys` / `RequireAtLeastOne`. Co-Authored-By: Claude Opus 4.7 --- source/non-empty-object.d.ts | 10 +++++++++- test-d/non-empty-object.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/source/non-empty-object.d.ts b/source/non-empty-object.d.ts index f42a2db5e..8e0447e00 100644 --- a/source/non-empty-object.d.ts +++ b/source/non-empty-object.d.ts @@ -1,4 +1,5 @@ import type {HasRequiredKeys} from './has-required-keys.d.ts'; +import type {OmitIndexSignature} from './omit-index-signature.d.ts'; import type {RequireAtLeastOne} from './require-at-least-one.d.ts'; /** @@ -6,6 +7,8 @@ Represents an object with at least 1 non-optional key. This is useful when you need an object where all keys are optional, but there must be at least 1 key. +Note: A type whose only members are index signatures (e.g. `{[key: string]: unknown}`) cannot statically express "at least one dynamic key" in TypeScript. For such types, `NonEmptyObject` fails closed and resolves to `never` rather than silently accepting `{}`. + @example ``` import type {NonEmptyObject} from 'type-fest'; @@ -33,6 +36,11 @@ const update2: UpdateRequest = {}; @category Object */ -export type NonEmptyObject = HasRequiredKeys extends true ? T : RequireAtLeastOne; +export type NonEmptyObject = + keyof OmitIndexSignature extends never + ? never + : HasRequiredKeys> extends true + ? T + : RequireAtLeastOne>; export {}; diff --git a/test-d/non-empty-object.ts b/test-d/non-empty-object.ts index d9fa6348d..6e4af0f4e 100644 --- a/test-d/non-empty-object.ts +++ b/test-d/non-empty-object.ts @@ -27,3 +27,35 @@ expectType(test1); expectType>(test2); expectType(test3); expectNever(test4); + +// Issue #821, behavior 1: a pure string index signature resolves to `never`. +type StringIndexOnly = {[key: string]: string | number | undefined}; +declare const stringIndexOnly: NonEmptyObject; +expectNever(stringIndexOnly); + +// Issue #821, behavior 2: a pure number index signature resolves to `never`. +type NumberIndexOnly = {[key: number]: unknown}; +declare const numberIndexOnly: NonEmptyObject; +expectNever(numberIndexOnly); + +// Issue #821, behavior 3: the exact repro from the issue — assigning `{foo: {}}` +// to an index-signature-only `NonEmptyObject` is rejected. +type CommonArguments = { + [filter: string]: NonEmptyObject<{[argument: string]: string | number | undefined}>; +}; +// @ts-expect-error — `{}` is not assignable because the inner type resolves to `never`. +const _commonArguments: CommonArguments = {foo: {}}; + +// Issue #821, behavior 4: an index signature combined with a required explicit +// key still resolves to the original type — the explicit required key anchors +// non-emptiness. +type MixedRequired = {[key: string]: string; a: string}; +declare const mixedRequired: NonEmptyObject; +expectType(mixedRequired); + +// Issue #821, behavior 5: an index signature combined with only optional +// explicit keys requires at least one of those explicit optional keys (rather +// than silently degenerating to the input type). +type MixedOptional = {[key: string]: string | undefined; a?: string}; +declare const mixedOptional: NonEmptyObject; +expectType>(mixedOptional);