Back to blog

Prevent TypeScript from Inferring

3 min read

Cover image for Prevent TypeScript from Inferring
Image crafted by robots

Type inference is usually a win.

It removes boilerplate. It keeps APIs ergonomic. It makes generics feel invisible.

It also has a failure mode.

Sometimes TypeScript infers from a place you did not intend.

This post shows a common way that happens with generics and callbacks, then fixes it with NoInfer.

Note

I use a form component as a concrete example. The pattern shows up anywhere you have generics plus callbacks.

Start with a simple form component.

It accepts initialValues and an onSubmit callback.

interface
interface FormProps
FormProps
{
FormProps.initialValues: any
initialValues
: any;
FormProps.onSubmit?: ((values: any) => void) | undefined
onSubmit
?: (
values: any
values
: any) => void;
}
function
function Form({ initialValues, onSubmit }: FormProps): JSX.Element
Form
({
initialValues: any
initialValues
,
onSubmit: ((values: any) => void) | undefined
onSubmit
}:
interface FormProps
FormProps
) {
return <
React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>
form
/>;
}

This compiles, but it gives you nothing.

The values parameter is any, so TypeScript cannot protect you.

function
function App(): JSX.Element
App
() {
return (
<
function Form({ initialValues, onSubmit }: FormProps): JSX.Element
Form
FormProps.initialValues: any
initialValues
={{
name: string
name
: "",
email: string
email
: "" }}
FormProps.onSubmit?: ((values: any) => void) | undefined
onSubmit
={(values) => {
values: any
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
values: any
values
.
any
name
,
values: any
values
.
any
email
);
}}
/>
);
}

If the form shape changes or you mistype a property, TypeScript stays quiet.

Step 1: make the component generic

The obvious fix is to make Form generic and let initialValues drive the type.

interface
interface FormProps<T>
FormProps
<
function (type parameter) T in FormProps<T>
T
> {
FormProps<T>.initialValues: T
initialValues
:
function (type parameter) T in FormProps<T>
T
;
FormProps<T>.onSubmit?: ((values: T) => void) | undefined
onSubmit
?: (
values: T
values
:
function (type parameter) T in FormProps<T>
T
) => void;
}
function
function Form<T>({ initialValues, onSubmit }: FormProps<T>): JSX.Element
Form
<
function (type parameter) T in Form<T>({ initialValues, onSubmit }: FormProps<T>): JSX.Element
T
>({
initialValues: T
initialValues
,
onSubmit: ((values: T) => void) | undefined
onSubmit
}:
interface FormProps<T>
FormProps
<
function (type parameter) T in Form<T>({ initialValues, onSubmit }: FormProps<T>): JSX.Element
T
>) {
return <
React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>
form
/>;
}

Now initialValues becomes the source of truth.

TypeScript infers T from what you pass in.

function
function App(): JSX.Element
App
() {
return (
<
function Form<{
name: string;
email: string;
}>({ initialValues, onSubmit }: FormProps<{
name: string;
email: string;
}>): JSX.Element
Form
FormProps<{ name: string; email: string; }>.initialValues: {
name: string;
email: string;
}
initialValues
={{
name: string
name
: "",
email: string
email
: "" }}
FormProps<{ name: string; email: string; }>.onSubmit?: ((values: {
name: string;
email: string;
}) => void) | undefined
onSubmit
={(values) => {
values: {
name: string;
email: string;
}
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
values: {
name: string;
email: string;
}
values
. )
email
name
}}
/>
);
}

You get autocomplete and type safety.

And if you access a property that does not exist, TypeScript catches it.

function
function App(): JSX.Element
App
() {
return (
<
function Form<{
name: string;
email: string;
}>({ initialValues, onSubmit }: FormProps<{
name: string;
email: string;
}>): JSX.Element
Form
FormProps<{ name: string; email: string; }>.initialValues: {
name: string;
email: string;
}
initialValues
={{
name: string
name
: "",
email: string
email
: "" }}
FormProps<{ name: string; email: string; }>.onSubmit?: ((values: {
name: string;
email: string;
}) => void) | undefined
onSubmit
={(
values: {
name: string;
email: string;
}
values
) => {
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
values: {
name: string;
email: string;
}
values
.address);
Error ts(2339) ― Property 'address' does not exist on type '{ name: string; email: string; }'.
}}
/>
);
}

So far this looks perfect.

Then refactors happen.

Step 2: inference can come from the wrong place

Here is the scenario that bites.

Before generics, people often “fixed” the callback by annotating it manually.

That was the only way to get types.

const
const initialValues: any
initialValues
: any = {};
function
function App(): JSX.Element
App
() {
return (
<
function Form({ initialValues, onSubmit }: FormProps): JSX.Element
Form
FormProps.initialValues: any
initialValues
={
const initialValues: any
initialValues
}
FormProps.onSubmit?: ((values: any) => void) | undefined
onSubmit
={(
values: {
name: string;
email: string;
}
values
: {
name: string
name
: string;
email: string
email
: string }) => {
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
values: {
name: string;
email: string;
}
values
.
name: string
name
,
values: {
name: string;
email: string;
}
values
.
email: string
email
);
}}
/>
);
}

Then you add generics.

function
function App(): JSX.Element
App
() {
return (
<Form
function Form<{
name: string;
email: string;
}>({ initialValues, onSubmit }: FormProps<{
name: string;
email: string;
}>): JSX.Element
FormProps<{ name: string; email: string; }>.initialValues: {
name: string;
email: string;
}
initialValues
={
const initialValues: any
initialValues
}
FormProps<{ name: string; email: string; }>.onSubmit?: ((values: {
name: string;
email: string;
}) => void) | undefined
onSubmit
={(
values: {
name: string;
email: string;
}
values
: {
name: string
name
: string;
email: string
email
: string }) => {
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
values: {
name: string;
email: string;
}
values
.
name: string
name
,
values: {
name: string;
email: string;
}
values
.
email: string
email
);
}}
/>
);
}

Here is what just happened.

Because initialValues is any, it provides no useful signal for inference.

So TypeScript looks elsewhere.

It sees the annotated callback and infers T from that instead.

That means your callback annotation becomes the source of truth.

Not initialValues.

That is backwards.

This is the bug.

You wanted inference to come from the values.

TypeScript inferred from the callback.

Step 3: tell TypeScript where not to infer

This is what NoInfer is for.

Wrap the callback parameter in NoInfer<T>.

interface
interface FormProps<T>
FormProps
<
function (type parameter) T in FormProps<T>
T
> {
FormProps<T>.initialValues: T
initialValues
:
function (type parameter) T in FormProps<T>
T
;
FormProps<T>.onSubmit?: ((values: NoInfer<T>) => void) | undefined
onSubmit
?: (
values: NoInfer<T>
values
:
type NoInfer<T> = intrinsic

Marker for non-inference type position

NoInfer
<
function (type parameter) T in FormProps<T>
T
>) => void;
}
function
function Form<T>({ initialValues, onSubmit }: FormProps<T>): JSX.Element
Form
<
function (type parameter) T in Form<T>({ initialValues, onSubmit }: FormProps<T>): JSX.Element
T
>({
initialValues: T
initialValues
,
onSubmit: ((values: NoInfer<T>) => void) | undefined
onSubmit
}:
interface FormProps<T>
FormProps
<
function (type parameter) T in Form<T>({ initialValues, onSubmit }: FormProps<T>): JSX.Element
T
>) {
return <
React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>
form
/>;
}

Read that as:

values is T, but do not use this position to infer T.”

Now inference must come from initialValues.

function
function App(): JSX.Element
App
() {
return (
<
function Form<{
name: string;
email: string;
}>({ initialValues, onSubmit }: FormProps<{
name: string;
email: string;
}>): JSX.Element
Form
FormProps<{ name: string; email: string; }>.initialValues: {
name: string;
email: string;
}
initialValues
={{
name: string
name
: "",
email: string
email
: "" }}
FormProps<{ name: string; email: string; }>.onSubmit?: ((values: NoInfer<{
name: string;
email: string;
}>) => void) | undefined
onSubmit
={(values) => {
values: NoInfer<{
name: string;
email: string;
}>
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
values: {
name: string;
email: string;
}
values
.
name: string
name
,
values: {
name: string;
email: string;
}
values
.
email: string
email
);
}}
/>
);
}

And if someone keeps an old annotation that does not match, TypeScript stops them.

function
function App(): JSX.Element
App
() {
return (
<
function Form<{
name: string;
email: string;
}>({ initialValues, onSubmit }: FormProps<{
name: string;
email: string;
}>): JSX.Element
Form
FormProps<{ name: string; email: string; }>.initialValues: {
name: string;
email: string;
}
initialValues
={{
name: string
name
: "",
email: string
email
: "" }}
onSubmit={(
values: {
name: string;
email: string;
address: string;
}
values
: {
name: string
name
: string;
email: string
email
: string;
address: string
address
: string }) => {
Error ts(2322) ― Type '(values: { name: string; email: string; address: string; }) => void' is not assignable to type '(values: NoInfer<{ name: string; email: string; }>) => void'. Types of parameters 'values' and 'values' are incompatible. Property 'address' is missing in type '{ name: string; email: string; }' but required in type '{ name: string; email: string; address: string; }'.
var console: Console
console
.
Console.log(...data: any[]): void

The console.log() static method outputs a message to the console.

MDN Reference

log
(
values: {
name: string;
email: string;
address: string;
}
values
.
name: string
name
);
}}
/>
);
}

That is exactly what you want during a refactor.

Old annotations should not silently redefine your generic.

If you are not on TypeScript 5.4

NoInfer shipped in TypeScript 5.4.

If you are stuck on an older version, you can approximate the behavior with a type trick:

type NoInfer<T> = [T][T extends any ? 0 : never];

It creates an inference barrier while preserving the underlying structure of T.

Once you upgrade, prefer the built-in NoInfer.

Behind the scenes

In lib.d.ts, NoInfer is defined like this:

type NoInfer<T> = intrinsic;

That “intrinsic” part matters.

It means the compiler implements the behavior directly.

TypeScript treats NoInfer<T> as “this is T, but do not collect inference candidates from here.”

So the type stays the same, but inference ignores that position.

Recap

Inference is not magic. It is a set of rules.

When a generic appears in multiple positions, TypeScript can infer from any of them.

Sometimes that is helpful.

Sometimes it picks the wrong source.

Use NoInfer<T> when:

This keeps your generics predictable.

And it keeps the “source of truth” where you intended it to be.

Questions or feedback? Send me an email.

Last updated on

Back to blog