Log Entry

Typed Config API Inference Edge Case

Feb 25, 2026 · 13 min read

What I was building

I was building a typed config API for a library-ish module inside an app. The input is an object keyed by string, and each key has a pair of functions.

The pattern was simple on paper:

  • one function computes metadata (T)
  • another function consumes that metadata (T)

So if func returns { id: number }, then otherFunc should receive response.metadata.id as number.

I wanted the call site to stay clean. No repeated generic arguments. No copy-pasting types at every property. Just write the object once, let inference do its job, move on.

That mostly worked until I hit one edge case where a tiny annotation change made T collapse.

The repro

declare function somePromise(request: Request): { id: number };

type Config<T> = {
  func(request: Request): Promise<T>;
  otherFunc(response: { metadata: T }): void;
};

export declare function Helper<T extends Record<string, unknown>>(
  input: { [K in keyof T]: Config<T[K]> },
): void;

export const SomeFunc = Helper({
  someKey: {
    func: async (request: Request) => {
      // Removing `: Request` breaks inference -- why?
      const { id } = await somePromise(request);
      return { id };
    },
    otherFunc: async (response) => {
      // EXPECTED: response.metadata.id is number
      // ACTUAL (when `request: Request` removed): response.metadata becomes unknown/loses precision
      response.metadata.id;
    },
  },
});

What I expected

I expected response.metadata.id to be number in otherFunc.

That's the whole point of this shape: func produces metadata, otherFunc consumes the same metadata type.

What actually happened

The weird part is that both versions look very similar in the editor.

Working case:

  • keep (request: Request) on func
  • func returns { id: number }
  • hover on response in otherFunc is effectively { metadata: { id: number } }
  • response.metadata.id is number

Broken case:

  • remove : Request from func
  • request still looks contextually typed as Request (so this feels safe at first glance)
  • but inference for T degrades
  • hover on response in otherFunc becomes something like { metadata: unknown } (or otherwise loses the precise { id: number } shape)

So one annotation that looked redundant turned out to be carrying inference weight.

A short "works" variation:

Helper({
  someKey: {
    func: async (request: Request) => {
      const { id } = await somePromise(request);
      return { id };
    },
    otherFunc: (response) => {
      response.metadata.id; // number
    },
  },
});

A short "breaks" variation:

Helper({
  someKey: {
    func: async (request) => {
      const { id } = await somePromise(request);
      return { id };
    },
    otherFunc: (response) => {
      response.metadata.id; // metadata loses precision (often unknown)
    },
  },
});

Why this happens (the non-hand-wavy version)

This setup is Helper&lt;T&gt;() where T is inferred from an object argument.

That object argument is constrained by a mapped type:

{ [K in keyof T]: Config&lt;T[K]&gt; }

That puts the compiler into reverse-mapped-type inference territory. This is the part where inference is real but less forgiving.

In plain terms:

  • the compiler needs T to type each property
  • but each property also contains context-sensitive function expressions
  • those function expressions may need T to be known first
  • now inference and contextual typing are pulling on each other in a cycle

TypeScript often avoids using context-sensitive function expressions as strong early inference sources in generic inference, especially when the useful signal comes from function bodies/returns. That's why removing an annotation can change whether a good inference path is available.

TypeScript 4.7 improved left-to-right inference inside object literals and methods (the "intra-expression inference" improvements), which helped a lot of real code. But this reverse-mapped shape is still an edge where those improvements don't always carry through cleanly. The issue thread for this family of behavior is here: microsoft/TypeScript#47599, and related discussion is in microsoft/TypeScript#53018.

TypeScript 6.0's "Less Context-Sensitivity on this-less Functions" is a different knob. It helps cases where method syntax was treated as context-sensitive mainly because of implicit this, and this isn't actually used. That change is good, but it doesn't rescue this repro by itself, because the failure mode here is unannotated params plus nested mapped/reverse inference boundaries, not implicit-this behavior. See the release post: Announcing TypeScript 6.0 Beta and the 4.7 notes: Improved Function Inference in Objects and Methods.

If you want receipts, those four links are the useful ones.

What I tried

I tried the obvious fixes first, then the API-shape fixes.

Adding request: Request back:

  • Works
  • Cheap fix
  • Annoying because it feels redundant and you have to repeat it everywhere
func: async (request: Request) => {
  const { id } = await somePromise(request);
  return { id };
}

Adding an explicit return type to func:

  • Works
  • More intention-revealing than annotating the request param
  • Still annotation tax
func: async (request): Promise<{ id: number }> => {
  const { id } = await somePromise(request);
  return { id };
}

Supplying explicit generic to Helper:

  • Works
  • Very predictable
  • Less ergonomic, and generic argument can get noisy in bigger objects
Helper<{ someKey: { id: number } }>({
  someKey: {
    func: async (request) => {
      const { id } = await somePromise(request);
      return { id };
    },
    otherFunc: (response) => {
      response.metadata.id; // number
    },
  },
});

Wrapping each entry in a helper (defineConfig) / builder pattern:

  • Works in practice
  • Better inference boundaries
  • Not free: extra abstraction, extra types, more surface area
const defineConfig = <T>(c: Config<T>) => c;

Helper({
  someKey: defineConfig({
    func: async (request) => {
      const { id } = await somePromise(request);
      return { id };
    },
    otherFunc: (response) => {
      response.metadata.id; // number
    },
  }),
});

Using satisfies:

  • Good for shape checking
  • Not a magic inference fix for this exact generic inference path
  • It can prevent widening and catch mismatches, but it doesn't automatically force Helper to infer the precise metadata map the way you might hope

Minimal example of what it does help with:

const input = {
  someKey: {
    func: async (request) => {
      const { id } = await somePromise(request);
      return { id };
    },
    otherFunc: (response) => {
      // still may not get ideal inference from Helper boundary alone
      response.metadata;
    },
  },
} satisfies Record<string, Config<unknown>>;

One thing that "sort of worked but only one layer deep" was helper wrappers. I could improve inference at the entry level, but once I stacked more generic mapping layers, I still had to add explicit hints somewhere.

What I'd do next time

If I control the API shape as a library author, I'd use a two-step registration/builder approach to create better inference boundaries. Split "produce metadata" and "consume metadata" into steps where T is locked before the second callback is typed.

If I'm consuming an existing API, I'd standardize the least painful workaround team-wide. For me that's usually explicit return type on producer functions, because it documents intent and keeps callback params less noisy.

If I must keep the exact API shape, I'd accept a small annotation tax and move on. Chasing perfect zero-annotation inference at this boundary is usually not worth the team's time.

Takeaways

  • Reverse mapped type inference is where clean-looking APIs can lose precision fast.
  • Contextual typing and generic inference are related, but not interchangeable.
  • "It hovers as Request" does not guarantee downstream generic inference stayed precise.
  • Tiny annotations can be structural inference hints, not just stylistic noise.
  • TS 4.7 helped a lot, but this edge still appears in mapped/reverse-mapped patterns.
  • TS 6.0's this-less function change is useful, but it targets a different class of inference pain.
  • satisfies is great for validation, not a universal inference repair tool.
  • If API shape is fixed, a small explicit annotation is often the most pragmatic answer.

Further reading