RequireAtLeastOne Utility Type
API endpoints often want 'pass at least one of these search params, but not necessarily all of them' (`{ id }`, `{ email }`, `{ id, email }`, but never `{}`). TypeScript's stock utilities cannot express that constraint, but a small `RequireAtLeastOne<T>` does. This snippet builds the type, layers in a `RequireExactlyOne` variant for either-or constraints, and shows a runtime guard that pairs with both.
456 views
2
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>>
& { [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>> }[Keys];
interface SearchInput {
id?: string;
email?: string;
handle?: string;
}
type Search = RequireAtLeastOne<SearchInput>;
const byId = { 'id': 'u1' } as Search;
const byEmail = { 'email': '[email protected]' } as Search;
console.log(byId.id, byEmail.email);The type expands into a union: for each candidate key K, produce a shape where K is required and the other keys are optional, then union all of those shapes together. Pick<T, Exclude<keyof T, Keys>> keeps any non-candidate keys intact (useful when only a subset of T's keys participate in the constraint). The runtime sentinel only fills one key at a time, exactly the contract you want at the API boundary. Without this constraint, Partial<SearchInput> would silently allow {}, which usually means a buggy caller hitting the database with no filter.
type RequireExactlyOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>>
& { [K in Keys]: Required<Pick<T, K>> & { [P in Exclude<Keys, K>]?: never } }[Keys];
interface PaymentInput {
cardToken?: string;
bankAccount?: string;
cryptoAddress?: string;
amount: number;
}
type Payment = RequireExactlyOne<PaymentInput, 'cardToken' | 'bankAccount' | 'cryptoAddress'>;
const card = { 'cardToken': 'tok_1', 'amount': 1000 } as Payment;
const bank = { 'bankAccount': 'iban', 'amount': 1000 } as Payment;
console.log(card.amount, bank.amount);The variant differs by setting the other candidate keys to never instead of optional, so passing two of them at once becomes a compile error. This shape is the right one for payment methods, login channels, or any union where exactly one of several mutually exclusive fields must appear. The Keys parameter lets you scope the constraint to a subset of T's keys (so amount stays free in the example) without rebuilding the type. Pair with a runtime guard to enforce the same shape against incoming JSON, since TS erases the constraint at the boundary.
function hasAtLeastOne<T extends object>(value: T, keys: Array<keyof T>): boolean {
return keys.some((k) => value[k] !== undefined && value[k] !== null);
}
function hasExactlyOne<T extends object>(value: T, keys: Array<keyof T>): boolean {
let count = 0;
for (const k of keys) {
if (value[k] !== undefined && value[k] !== null) count += 1;
}
return count === 1;
}
console.log(hasAtLeastOne({ 'id': 'x' }, ['id', 'email']));
console.log(hasExactlyOne({ 'id': 'x', 'email': 'y' }, ['id', 'email']));
console.log(hasExactlyOne({ 'id': 'x' }, ['id', 'email']));The static type catches misuse in TS code, but the moment a request arrives from the wire (JSON body, query string, third-party SDK), the guarantee evaporates. Pairing the type with a tiny runtime helper lets the boundary code reject malformed payloads with a 400 instead of silently accepting them. Both helpers are O(k) over the candidate key list and accept any keyof T array, so callers can derive the keys from the same source as the type with Object.keys or a hand-maintained tuple. Wire these into your validation layer (Zod, valibot, custom) for end-to-end safety.
