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.
I use a form component as a concrete example. The pattern shows up anywhere you have generics plus callbacks.
Step 0: any breaks the type link
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.
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.
log(values: { name: string; email: string;}
values. )emailname }} /> );}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.
log(values: { name: string; email: string;}
values.address);Error ts(2339) ― }} /> );}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.
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 ( <Formfunction 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.
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.
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) ― var console: Console
console.Console.log(...data: any[]): void
The console.log() static method outputs a message to the console.
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:
- You want inference to come from one argument
- You want callbacks to follow that inferred type
- You do not want parameter annotations to redefine your generic during refactors
This keeps your generics predictable.
And it keeps the “source of truth” where you intended it to be.