XSCREENSAVER / 3D PIPES

[readonly] markdown buffer

Typed Config API Inference Edge Case

Feb 25, 2026 · 6 min read

I wanted a typed config API where one callback produces metadata and another consumes the same inferred type. It worked until removing an apparently redundant parameter annotation turned the consumer's metadata into unknown.

The minimal shape

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

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

With an explicit producer parameter, inference succeeds:

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

Remove : Request and the parameter can still appear contextually typed in the editor, while the downstream T loses its precise { id: number } shape.

Why the annotation carries weight

T is inferred backwards from an object constrained by a mapped type:

{ [K in keyof T]: Config<T[K]> }

Each property contains context-sensitive functions. The compiler needs T to type those functions, but their return values are also evidence for T. Contextual typing and generic inference therefore depend on each other.

TypeScript avoids treating some context-sensitive functions as strong early inference sources. Removing the annotation can remove the path that allowed the producer's return type to reach the consumer, even though the producer parameter still looks correctly typed locally.

TypeScript 4.7 improved left-to-right inference for functions in object literals, but reverse-mapped boundaries remain less forgiving. TypeScript 6.0's reduced context sensitivity for this-less functions targets a related but different problem; it does not remove this cycle.

Four practical responses

1. Restore the parameter annotation

func: async (request: Request) => {
  // ...
}

This is the smallest fix. The repetition is annoying, but predictable.

2. Annotate the producer's result

func: async (request): Promise<{ id: number }> => {
  // ...
}

This often communicates the contract better because the return type is the value shared with the consumer.

3. Supply the metadata map explicitly

helper<{ someKey: { id: number } }>({
  // ...
});

It is reliable but becomes noisy for a large configuration.

4. Create a smaller inference boundary

const defineConfig = <T>(config: Config<T>) => config;

helper({
  someKey: defineConfig({
    func: async (request) => ({ id: (await somePromise(request)).id }),
    otherFunc: (response) => response.metadata.id,
  }),
});

A builder is worthwhile when you own a public API and many callers hit the same problem. For one internal call site, it can be more abstraction than the problem deserves.

satisfies remains useful for validating shape and preventing widening, but it is not a universal repair for this inference path.

The pragmatic answer

If I own the API, I would split production and consumption into stages so T is fixed before the consumer is typed. If the shape is fixed, I would standardise an explicit producer return type and move on.

Zero-annotation inference is pleasant until the API requires the compiler to infer a type from callbacks that themselves need that type. At that boundary, one small annotation is often better than a clever type-level workaround.

Further reading