Back to blog

Sometimes All You Need Is a Return Type Annotation

How TypeScript's type inference can fail in deeply nested contexts, causing union types to widen unexpectedly, and why explicit return type annotations are critical.

typescript effect-ts systems

TL;DR: When you see TypeScript widening your literal union types to string in complex nested contexts (like Effect.gen with nested Array.reduce), adding an explicit return type annotation to your helper functions often solves the problem immediately.

The Problem

Technical Context: Union Type Widening in Nested Reducers

I encountered a perplexing TypeScript type error where the compiler incorrectly inferred a union type with inconsistent property types, even though the actual runtime behavior was correct.

Error Message

Argument of type '(acc: Record<string, { utilizationLevel: "high" | "medium" | "low"; utilization: number; overnightGuests: number; overnightGuestsLevel: "high" | "medium" | "low"; }>, day: DateTime<...>) => { ...; }' is not assignable to parameter of type '(b: Record<string, { utilizationLevel: "high" | "medium" | "low"; utilization: number; overnightGuests: number; overnightGuestsLevel: "high" | "medium" | "low"; }>, a: DateTime<...>, i: number) => Record<...>'.
Type '{ [x: string]: { utilizationLevel: "high" | "medium" | "low"; utilization: number; overnightGuests: number; overnightGuestsLevel: "high" | "medium" | "low"; } | { utilizationLevel: string; utilization: number; overnightGuests: number; overnightGuestsLevel: "high" | ... 1 more ... | "low"; }; }' is not assignable to type 'Record<string, { utilizationLevel: "high" | "medium" | "low"; utilization: number; overnightGuests: number; overnightGuestsLevel: "high" | "medium" | "low"; }>'.
'string' index signatures are incompatible.
Type '{ utilizationLevel: "high" | "medium" | "low"; utilization: number; overnightGuests: number; overnightGuestsLevel: "high" | "medium" | "low"; } | { utilizationLevel: string; utilization: number; overnightGuests: number; overnightGuestsLevel: "high" | ... 1 more ... | "low"; }' is not assignable to type '{ utilizationLevel: "high" | "medium" | "low"; utilization: number; overnightGuests: number; overnightGuestsLevel: "high" | "medium" | "low"; }'.
Type '{ utilizationLevel: string; utilization: number; overnightGuests: number; overnightGuestsLevel: "high" | "medium" | "low"; }' is not assignable to type '{ utilizationLevel: "high" | "medium" | "low"; utilization: number; overnightGuests: number; overnightGuestsLevel: "high" | "medium" | "low"; }'.
Types of property 'utilizationLevel' are incompatible.
Type 'string' is not assignable to type '"high" | "medium" | "low"'.ts(2345)

The error indicated that TypeScript thought my object could have two different shapes:

  1. One with utilizationLevel: "high" | "medium" | "low" (correct)
  2. One with utilizationLevel: string (incorrect widening)

Notice that only the utilizationLevel property was widened to string, while overnightGuestsLevel remained correctly typed as "high" | "medium" | "low" in both variants. This is a crucial clue about what went wrong.

The Code

// Before: Function without explicit return type
function getUtilizationLevel(count: number) {
if (count >= highUtilizationThreshold) return "high";
if (count >= lowUtilizationThreshold) return "medium";
return "low";
}
// Usage in nested Array.reduce
return Array.reduce(eventDays, allDaysAcc, (acc, day) => {
const utilizationLevel = getUtilizationLevel(newCount);
return {
...acc,
[dateKey]: {
utilizationLevel: utilizationLevel,
utilization: newCount,
overnightGuests: newOvernightGuests,
overnightGuestsLevel: overnightGuestsLevel,
},
};
});

The Solution

Adding an explicit return type annotation to getUtilizationLevel immediately resolved the issue:

// After: Function with explicit return type
function getUtilizationLevel(count: number): "low" | "medium" | "high" {
if (count >= highUtilizationThreshold) return "high";
if (count >= lowUtilizationThreshold) return "medium";
return "low";
}

No other changes were required. The type error disappeared completely.

Why This Happened: Deep Dive into TypeScript’s Type Inference

1. Control Flow Analysis Limitations in Complex Contexts

TypeScript’s control flow analysis is sophisticated but has limitations when dealing with:

  • Nested function calls
  • Higher-order functions (like Array.reduce)
  • Multiple levels of type inference

In my case, I had:

Effect.gen → Array.reduce (outer) → Array.reduce (inner) → getUtilizationLevel

At this depth, TypeScript’s ability to narrow types through control flow analysis becomes less reliable.

2. Type Widening in Inference

Without an explicit return type, TypeScript infers the return type of getUtilizationLevel by examining all return statements:

function getUtilizationLevel(count: number) {
if (count >= highUtilizationThreshold) return "high"; // literal type "high"
if (count >= lowUtilizationThreshold) return "medium"; // literal type "medium"
return "low"; // literal type "low"
}

TypeScript should infer the return type as "low" | "medium" | "high". However, in complex contexts (nested reducers, Effect.gen), the compiler may:

  • Widen the type to string as a “safe” fallback
  • Create a union of possible inferred types at different call sites
  • Struggle to unify the type across multiple code paths

3. Spread Operator and Index Signatures

The spread operator combined with computed property names creates additional complexity:

return {
...acc, // Type of acc must be unified
[dateKey]: { // Computed property - dynamic key
utilizationLevel: ..., // Type must be consistent across all branches
},
};

TypeScript must ensure that:

  • The spread ...acc is type-safe
  • The new property [dateKey] is compatible with the accumulated type
  • All possible return values from the reducer have the same type

When getUtilizationLevel lacks an explicit return type, TypeScript may conservatively widen to string in some branches to ensure type safety.

4. Effect.gen and Contextual Typing

The Effect.gen generator function adds another layer of complexity. TypeScript must:

  • Infer types through generator yields
  • Maintain type information across async boundaries
  • Handle the Effect monad’s type transformations

This can interfere with type narrowing, especially for functions called deep within the generator.

5. Union Type Propagation

The error message showing a union of two object shapes suggests TypeScript created different type inferences for different code paths:

// Path 1: TypeScript successfully narrows
{ utilizationLevel: "low" | "medium" | "high"; ... }
// Path 2: TypeScript widens to string (fallback)
{ utilizationLevel: string; ... }

This happens when the compiler cannot prove that all code paths produce the same type, so it creates a union of possibilities.

Key Takeaways

When to Add Explicit Return Types

  1. Functions returning union types of literals - Always annotate these explicitly
  2. Functions used in nested contexts - Reducers, generators, higher-order functions
  3. Functions used across module boundaries - Especially when exported
  4. When you see unexpected type widening - string instead of "a" | "b" | "c"

Benefits of Explicit Return Types

  1. Predictable type inference - No surprises from compiler heuristics
  2. Better error messages - Errors appear at the function definition, not at call sites
  3. Self-documenting code - The return type is immediately visible
  4. Compiler performance - Less work for the type checker
  5. Refactoring safety - Changes to implementation won’t accidentally change the public type

Best Practice

// ❌ Avoid: Implicit return type in complex scenarios
function getStatus(code: number) {
if (code === 200) return "success";
if (code === 404) return "not_found";
return "error";
}
// ✅ Prefer: Explicit return type
function getStatus(code: number): "success" | "not_found" | "error" {
if (code === 200) return "success";
if (code === 404) return "not_found";
return "error";
}

Conclusion

This issue demonstrates that TypeScript’s type inference, while powerful, has practical limits in deeply nested or complex contexts. The solution is simple: be explicit about return types for functions that return union types of literals, especially when those functions are used in complex control flow or nested function calls.

The small cost of adding a return type annotation pays dividends in type safety, code clarity, and developer experience.