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);