When designing library APIs and enterprise SDKs, engineers frequently implement polymorphic functions whose behavior and return type depend on the types or count of arguments passed. TypeScript provides two distinct mechanisms to achieve this:
T extends U ? X : Y.Although both approaches solve seemingly similar problems, they operate under fundamentally different type computation algorithms and diverge dramatically when processing union types.
+-------------------------------------------------------------------------+
| ARCHITECTURAL MECHANISM COMPARISON |
+-------------------------------------------------------------------------+
| [Approach 1: Function Overloading] |
| function get(id: string): Entity; |
| function get(ids: string[]): Entity[]; |
| |
| Model: Discrete linear list. |
| Algorithm: First-match (top-to-bottom). |
| Behavior with Union: CRASH! get(param: string | string[]) -> ERROR! |
+-------------------------------------------------------------------------+
vs
+-------------------------------------------------------------------------+
| [Approach 2: Conditional Generics] |
| function get<T extends string | string[]>(idOrIds: T): |
| T extends string[] ? Entity[] : Entity; |
| |
| Model: Distributive mathematical mapping. |
| Algorithm: Projected type evaluation over T. |
| Behavior with Union: WORKS! Result: Entity | Entity[] |
+-------------------------------------------------------------------------+The distinction between these mechanisms stems from compiler resolution algorithms:
The Union Trap with Overloaded Functions:
Signatures:
1) (x: string) => number
2) (x: number) => string
Call site:
const arg: string | number = getDynamicValue();
fn(arg); // ERROR TS2769: No overload matches this call.
Why?
arg is not strictly string (it might be number).
arg is not strictly number (it might be string).
The compiler does not automatically synthesize a combined union signature!findById('1') -> Entity) or an entity array when passed an ID list (findById(['1', '2']) -> Entity[]).{ suspense: true } is enabled.asDate: true.The classical overload pattern excels at granular JSDoc documentation, surfacing distinct IDE hints for each variant:
export interface StorageItem {
readonly key: string;
readonly value: string;
}
/**
* Overload 1: Retrieve single key.
* @param key - String identifier of storage item.
* @returns String value or null if key is missing.
*/
export function getStorage(key: string): string | null;
/**
* Overload 2: Batch retrieval of keys.
* @param keys - Array of identifiers.
* @returns Array of StorageItem objects.
*/
export function getStorage(keys: ReadonlyArray<string>): ReadonlyArray<StorageItem>;
// Implementation signature (hidden from consumers)
export function getStorage(
keyOrKeys: string | ReadonlyArray<string>
): string | null | ReadonlyArray<StorageItem> {
if (Array.isArray(keyOrKeys)) {
return keyOrKeys.map((k) => ({ key: k, value: `val_${k}` }));
}
return keyOrKeys === 'missing' ? null : `val_${keyOrKeys}`;
}
// Invocations:
const single = getStorage('user_id'); // Type: string | null
const multiple = getStorage(['key1', 'key2']); // Type: readonly StorageItem[]
// THE OVERLOAD TRAP:
function processQuery(input: string | string[]) {
// ❌ COMPILE ERROR TS2769: No overload matches this call.
// Overload 1 requires string, Overload 2 requires ReadonlyArray<string>.
// Type 'string | string[]' is not assignable to either.
// const result = getStorage(input);
}Re-implementing the same interface via distributive conditional types resolves union handling permanently:
export interface StorageItem {
readonly key: string;
readonly value: string;
}
export type StorageResult<T extends string | ReadonlyArray<string>> =
T extends ReadonlyArray<string> ? ReadonlyArray<StorageItem> : string | null;
/**
* Universal signature powered by conditional generics.
* Flawlessly accommodates concrete types as well as dynamic unions!
*/
export function getStorageGeneric<T extends string | ReadonlyArray<string>>(
keyOrKeys: T
): StorageResult<T> {
if (Array.isArray(keyOrKeys)) {
return keyOrKeys.map((k) => ({ key: k, value: `val_${k}` })) as StorageResult<T>;
}
const val = keyOrKeys === 'missing' ? null : `val_${keyOrKeys}`;
return val as StorageResult<T>;
}
// Invocations:
const single = getStorageGeneric('user_id'); // Type: string | null
const multiple = getStorageGeneric(['k1', 'k2'] as const); // Type: readonly StorageItem[]
// UNION TYPES OPERATE SEAMLESSLY:
function processQuerySafe(input: string | string[]) {
// ✅ SUCCESS: return type is mathematically inferred as:
// (string | null) | ReadonlyArray<StorageItem>
const result = getStorageGeneric(input);
}When rich JSDoc tooltips and union compatibility are both required, the hybrid pattern adds an explicit union overload signature:
// Signature for single resource
export function fetchResource(id: string): Promise<Response>;
// Signature for resource list
export function fetchResource(ids: string[]): Promise<Response[]>;
// Fallback overload signature dedicated to union types
export function fetchResource(ids: string | string[]): Promise<Response | Response[]>;
// Implementation signature
export function fetchResource(
ids: string | string[]
): Promise<Response | Response[]> {
if (Array.isArray(ids)) {
return Promise.all(ids.map((id) => fetch(`/api/${id}`)));
}
return fetch(`/api/${ids}`);
}Assess whether parameter counts differ between cases. If functions exhibit variable arity (e.g., f(a) vs f(a, b, c)), default to Function Overloads.
Will arguments arrive as union types (string | string[]) from external callers? If yes, strict overloads will break downstream code.
For identical parameter counts with a functional return dependency, use T extends Target ? R1 : R2.
When using conditional generics, extract logic into a clean Type Alias (StorageResult<T>) to preserve clear IntelliSense tooltips.
export type RawInput = string | number | boolean;
export interface NormalizedResult {
readonly raw: string;
readonly type: 'string' | 'number' | 'boolean';
readonly processedAt: number;
}
export type TransformOutput<T extends RawInput | ReadonlyArray<RawInput>> =
T extends ReadonlyArray<RawInput>
? ReadonlyArray<NormalizedResult>
: NormalizedResult;
/**
* Isomorphic data transformer.
* Seamlessly accepts scalars, arrays of scalars, or dynamic unions of both.
*/
export function transformInput<T extends RawInput | ReadonlyArray<RawInput>>(
input: T
): TransformOutput<T> {
const normalizeSingle = (val: RawInput): NormalizedResult => ({
raw: String(val),
type: typeof val as NormalizedResult['type'],
processedAt: Date.now(),
});
if (Array.isArray(input)) {
return (input as ReadonlyArray<RawInput>).map(normalizeSingle) as TransformOutput<T>;
}
return normalizeSingle(input as RawInput) as TransformOutput<T>;
}TypeScript resolves overloads in the exact order of declaration. Placing a wider type (string or any) ahead of a specific literal ('json') causes the specific overload to be completely unreachable.
// ❌ ANTIPATTERN: Broad string intercepts specific 'json' literal
function parseBad(format: string): unknown;
function parseBad(format: 'json'): Record<string, unknown>; // UNREACHABLE!
// ✅ CORRECT: Order from most specific to most general
function parseGood(format: 'json'): Record<string, unknown>;
function parseGood(format: string): unknown;Authoring overloaded signatures without union fallbacks creates friction for consumers passing dynamically typed union values.
// ❌ ANTIPATTERN: Overloads lacking a union fallback
function render(node: string): HTMLElement;
function render(node: number): Text;
function wrapper(val: string | number) {
// Error: No overload matches this call
return render(val);
}
// ✅ CORRECT: Replace overloads with a conditional generic
function render<T extends string | number>(node: T): T extends string ? HTMLElement : Text;Inlining deeply nested ternaries within function parameter lists makes code unmaintainable. Complex conditional logic should be decomposed into dedicated Type Aliases.
// ❌ ANTIPATTERN: Inlined ternary spaghetti
function bad<T>(x: T): T extends A ? (T extends B ? C : D) : (T extends E ? F : G) { ... }
// ✅ CORRECT: Decomposition into named utility types
type ResolveOutput<T> = ...;
function good<T>(x: T): ResolveOutput<T> { ... }If the implementation signature is too narrow or fails to encompass all overload signatures, TypeScript raises error TS2394: This overload signature is not compatible with its implementation signature.
// ❌ ANTIPATTERN: Implementation fails to cover number overload
function test(x: string): string;
function test(x: number): number;
function test(x: string): any { ... } // Error TS2394: number is unhandled!If use cases require fundamentally different numbers of arguments (varying arity), choose Function Overloads.
If parameter counts are identical and return types depend directly on argument types, use Generic + Conditional Type.
If callers pass dynamic union types, conditional types are mandatory to avoid TS2769 errors.
If each case requires unique JSDoc documentation, adopt the hybrid pattern with an explicit union overload.
Author type tests verifying resolution for both concrete literals and dynamic unions.