Designing Mutually Exclusive Fields in TypeScript Configurations
This article explains how to model a TypeScript configuration function where fields a and b are mutually exclusive (with an optional foo field), explores several type‑level solutions—including manual exclusive interfaces, function overloads, conditional types, and XOR utilities—and provides practical code examples and references.
The article introduces a TypeScript defineConfig function that should accept a configuration object where the fields a and b are mutually exclusive, while foo remains optional. It starts with a simple playground example and a motivation to make TS feel as seamless as JavaScript for users.
Tree and Recursion Example demonstrates a generic binary‑tree type and a depth‑first traversal that converts the tree into a list, showing how complex types can be used without explicit type annotations at call sites.
Avoiding Bad Design notes that mutually exclusive fields violate third normal form in relational design, suggesting that such data should be normalized rather than forced into a single table.
Solution 1 – Manual Exclusive Interfaces defines separate interfaces JsConfig and TsConfig extending a common base, using a?: never and b?: never to enforce exclusivity, then combines them with a union type UserConfig = JsConfig | TsConfig. Example calls show correct usage and compile‑time errors.
/**
* Export a defineConfig function where a and b are exclusive, foo is optional
*/
interface CommonConfig { foo?: string; }
interface JsConfig extends CommonConfig { a: boolean; b?: never; }
interface TsConfig extends CommonConfig { b: boolean; a?: never; }
type UserConfig = JsConfig | TsConfig;
function defineConfig(t: UserConfig) { /* ... */ }
// ok
defineConfig({ a: true });
defineConfig({ a: true, foo: 'aa' });
defineConfig({ b: true });
// error
defineConfig({ a: true, b: true });Solution 2 – Function Overloads provides two overload signatures, one for each exclusive interface, and a single implementation that accepts the union type.
function defineConfig(c: JsConfig): void;
function defineConfig(c: TsConfig): void;
function defineConfig(t: UserConfig) { /* ... */ }Solution 3 – Automatic Field Injection via Conditional Types creates utility types SetKeyNever, JustOne, and MergeIntersection to programmatically enforce that exactly one of a set of keys is present, then uses them in the defineConfig signature.
type SetKeyNever<T, K extends keyof T> = { [x in K]?: never };
type JustOne<T, K extends (keyof T)[] = [], Y extends keyof T = K[number]> = {
[x in Y]: Pick<T, Exclude<keyof T, Exclude<Y, x>>> & SetKeyNever<T, Exclude<Y, x>>;
}[Y];
function defineConfig(t: JustOne<UserConfig, ['a','b','c']>) { /* ... */ }Solution 4 – XOR Utility defines a generic XOR type that combines two object types so that only one can be supplied, then composes the final UserConfig as XOR<{a:boolean}, {b:boolean}> & { foo?: string }.
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
type UserConfig = XOR<{ a: boolean }, { b: boolean }> & { foo?: string };The article also includes a short FAQ showing how TypeScript distinguishes between overloads and union types, a note that unions are useful for Cartesian product type generation, and several external references on conditional types, EitherOr gymnastics, and converting unions to tuples.
Overall, the piece serves as a practical guide for TypeScript developers who need to enforce mutually exclusive configuration options while keeping the API ergonomic.
ByteDance Web Infra
ByteDance Web Infra team, focused on delivering excellent technical solutions, building an open tech ecosystem, and advancing front-end technology within the company and the industry | The best way to predict the future is to create it
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
