Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion source/non-empty-object.d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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';

/**
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';
Expand Down Expand Up @@ -33,6 +36,11 @@ const update2: UpdateRequest<User> = {};

@category Object
*/
export type NonEmptyObject<T extends object> = HasRequiredKeys<T> extends true ? T : RequireAtLeastOne<T, keyof T>;
export type NonEmptyObject<T extends object> =
keyof OmitIndexSignature<T> extends never
? never
Comment on lines +40 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve any inputs in NonEmptyObject

When callers instantiate NonEmptyObject<any> (or a generic that resolves to any), this new guard treats any like a pure index-signature type: OmitIndexSignature<any> maps over string | number | symbol and removes every key, so the conditional resolves to never. That regresses from the previous definition, which preserved any, and can turn permissive downstream generic APIs into impossible types.

Useful? React with 👍 / 👎.

: HasRequiredKeys<OmitIndexSignature<T>> extends true
? T
: RequireAtLeastOne<T, keyof OmitIndexSignature<T>>;

export {};
32 changes: 32 additions & 0 deletions test-d/non-empty-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,35 @@ expectType<TestType1>(test1);
expectType<RequireAtLeastOne<TestType2>>(test2);
expectType<TestType3>(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<StringIndexOnly>;
expectNever(stringIndexOnly);

// Issue #821, behavior 2: a pure number index signature resolves to `never`.
type NumberIndexOnly = {[key: number]: unknown};
declare const numberIndexOnly: NonEmptyObject<NumberIndexOnly>;
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<MixedRequired>;
expectType<MixedRequired>(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<MixedOptional>;
expectType<RequireAtLeastOne<MixedOptional, 'a'>>(mixedOptional);