How TypeScript inference works
See the rules TypeScript uses to work out types you never wrote: widening, the best common type, contextual typing, and inferring type arguments.
- 01Predict wideningSay when a value keeps its exact type and when it widens, and stop it with a type or
as const. - 02Follow both directionsTell a type that comes from the value, like the best common type, from one that comes from the place, like a callback's parameter.
- 03Steer type argumentsPredict how TypeScript fills in a generic's type argument, and use
NoInferor an explicit argument when it picks wrong.
Where a type comes from when you don't write one
In Annotations & inference you saw that TypeScript works out a type from a starting value. That is one rule of several. The checker (the part of the compiler that works out types, from Inside the compiler) follows a few fixed rules, and three lines of a cart already use four of them. Point at any name to see its type:
const cart = [{ name: "Tea", price: 3 }, { name: "Cake", price: 4 }];// ^?const total = cart.reduce((sum, item) => sum + item.price, 0);// ^?// ^?console.log(total);No type is written anywhere, yet every name has one:
- Line 1 makes a cart of two items. The query on line 2 shows each item's
nameasstring, not"Tea". That is widening: the exact text became the general type. - The cart is an array of one item type, worked out from both elements. That is the best common type.
- Line 3 adds up the prices with
reduce. The query on line 5 showsitemis a cart item, though its brackets say nothing about it. It got its type from the place it is written: the callback ofcart.reduce. That is contextual typing. - The query on line 4 shows
const total: number.reduceis a generic function (one with a type parameter, a placeholder for a type), and TypeScript filled in that placeholder from the starting value0. That is inferring a type argument.
Line 6 prints 7. The rules work in two directions, which the picture below shows:
"dark" decides the type of theme. On the right, the place the function is written, a click handler, decides the type of event.Widening and the best common type go from the value: TypeScript looks at what you wrote and works out its type. Contextual typing goes from the place: TypeScript looks at where you wrote it and passes the type in. Type arguments use both.
You are labelling jars without being told what they are for. A jar you'll refill with different spices gets a general label, "spice", not the name of today's spice. A shelf with salt and pepper gets a label that covers both. A jar placed on the shelf marked "flour" needs no label of its own: the shelf says what it holds.
- In real life: A jar you'll refill with any spice gets the label "spice"
- In TypeScript: A
letholding"dark"gets the typestring - In real life: A shelf of salt and pepper jars gets the label "salt or pepper"
- In TypeScript:
[5, null]gets the type(number | null)[] - In real life: A jar on the shelf marked "flour" is a flour jar
- In TypeScript: A callback passed to a "click" listener gets a click event
- In real life: A label maker that copies the first jar's label
- In TypeScript: A generic function whose type argument comes from its arguments
Inference is a few fixed rules, not a guess. From the value: a name gets its starting value's type, widened if the name can change, and several values get their best common type. From the place: a value written where a type is expected gets that type. A generic call fills in its type arguments from its arguments.
The table below lists the four rules. Read each row as: the rule, where it looks, an example, and the type it gives.
| The rule | Where it looks | Example | The type |
|---|---|---|---|
| Declarations and widening | The starting value, and whether the name can change. | let theme = "dark" | string |
| The best common type | Every value in an array or every return statement. | [5, null, 10] | (number | null)[] |
| Contextual typing | The place the value is written: a typed variable, a parameter, a callback. | addEventListener("click", (event) => …) | event: PointerEvent |
| Type argument inference | The arguments of a call to a generic function. | firstItem([3, 4]) | T is number |
Widening: when an exact value becomes a general type
Every value you write starts with its exact type: "dark" is the type "dark", and 16 is the type 16 (Literal types covers these). When TypeScript stores the value somewhere that can change later, it widens the exact type to the general one:
let theme = "dark";// ^?const fontSize = 16;// ^?const order = { size: "M", quantity: 2 };// ^?Line 1 is a let, which you can give any other text later, so the query on line 2 shows string. Line 3 is a const, which never changes, so it keeps 16. Line 5 is a const too, but its properties can still change (order.size = "L" is allowed), so the query on line 6 shows size: string.
That part you may know. Here is the part that surprises people. Press each button to declare theme a different way on line 3, then watch what copy becomes on line 4:
type Theme = "dark" | "light";function setTheme(theme: Theme) { console.log(`Theme: ${theme}`); }let theme = "dark";let copy = theme;// ^?setTheme(copy);Theme: darkstring1 error: line 6theme is a let, so it is a string from the start, and so is copy. Line 6 gets Argument of type 'string' is not assignable to parameter of type 'Theme': any string might not be a theme.
With const, theme is exactly "dark", and yet copy is a string. A const with no type written holds a widening exact type: exact while it stays in the const, widened as soon as it is copied into a let, a property, or an array. Writing the type (const with a type) or as const gives an exact type that doesn't widen, so copy stays "dark". The output is Theme: dark every time: widening only changes the types, never what runs.
Values that a function gives back follow a rule of their own:
function defaultTheme() { return "dark";}const theme = defaultTheme();// ^?function shippingCost(total: number) { if (total >= 50) return 0; return 4.99;}const cost = shippingCost(30);// ^?console.log(theme, cost);defaultTheme on lines 1 to 3 always gives back "dark", and the query on line 5 shows string: one exact value given back is widened. shippingCost on lines 6 to 9 gives back 0 or 4.99, and the query on line 11 shows 0 | 4.99: when the returns give back different exact values, TypeScript keeps them as a union (a type that is one of several). Line 12 prints dark 4.99.
The best common type of several values
When one type has to describe several values, like the elements of an array, each value gives a candidate type. TypeScript then picks the best common type: a candidate that all the others fit into. If no candidate covers the rest, it joins them in a union:
const prices = [3, 4, 5];// ^?const discounts = [5, null, 10];// ^?const tags = ["sale", 3];// ^?On line 1 every candidate is number, so the query on line 2 shows number[]. On line 3 the candidates are number and null, and the query on line 4 shows (number | null)[]. On line 5 they are string and number, so (string | number)[].
[5, null, 10] gives a candidate type. No candidate covers all the others, so TypeScript joins them: (number | null)[].Objects with different properties are joined too, with a small extra step:
const cart = [{ name: "Tea", price: 3 }, { name: "Gift card" }];// ^?const secondPrice = cart[1].price;// ^?console.log(secondPrice);Line 1 has a tea with a price and a gift card without one. The query on line 2 shows a union of the two shapes, and the gift card's shape has gained price?: undefined (an optional price that is always undefined). TypeScript adds it so that every item has a price to read. That is why line 3 is allowed, and the query on line 4 shows number | undefined. Line 5 prints undefined.
The best common type is always one of the candidates, or their union. TypeScript never invents a type you didn't write:
class Control { label = "Button"; }class SaveButton extends Control { save() {} }class CancelButton extends Control { cancel() {} } const toolbar = [new SaveButton(), new CancelButton()];// ^?const footer = [new Control(), new SaveButton()];// ^?const typed: Control[] = [new SaveButton(), new CancelButton()];Lines 1 to 3 make a Control class and two kinds of button that extend it (Classes covers extends). Line 5 puts a save button and a cancel button in an array, and the query on line 6 shows (SaveButton | CancelButton)[], not Control[]: Control isn't one of the candidates. On line 7 it is, and every SaveButton fits into a Control, so the query on line 8 shows Control[]. When you want the parent type, write it, as line 9 does.
Contextual typing: the place decides
Sometimes the type flows the other way. When you write a value in a place that already has a type, TypeScript gives the value that type. This is called contextual typing, and its most common use is a callback's parameters. Press each button to move an event handler (Introduction to events covers addEventListener):
const button = document.createElement("button");button.addEventListener("click", (event) => { console.log(event.button);});PointerEventNo errorsThe arrow function is written right where addEventListener expects a click handler. TypeScript looks up what a "click" handler receives and gives event that type: PointerEvent, a kind of MouseEvent. It has a button property, so line 3 is fine.
With Inline, click, event is a PointerEvent: TypeScript read the event name "click", looked up what a click handler receives in the browser's types, and gave event that type. Inline, keydown makes it a KeyboardEvent, which has no button, and line 3 gets an error. A separate const loses the context: on line 2 nothing says what the function is for, so TypeScript reports Parameter 'event' implicitly has an 'any' type. Inference never looks ahead to line 5. A separate const, typed fixes it by writing the type.
"click" handler receives, and that type goes to your event parameter. You wrote no type at all.Any place with a type gives context, not just function calls. Here, a typed object does:
type Settings = { theme: "dark" | "light"; onChange: (value: string) => void;}; const settings: Settings = { theme: "dark", onChange: (value) => console.log(`Saved ${value}`), // ^?};settings.onChange("dark");Lines 1 to 4 describe Settings: a theme and an onChange function that receives a string. Line 6 says settings is a Settings, so every property inside gets context. On line 8, value has no type written, and the query on line 9 shows string. The same context checks line 7: "dark" is compared with "dark" | "light" and keeps its exact type, so it doesn't widen. Line 11 prints Saved dark.
Inferring type arguments
A generic function has a type parameter, like T: a placeholder that each call fills in with a type argument (Generic functions introduces them). You rarely write the type argument, because TypeScript infers it from the arguments:
function firstItem<T>(items: T[]) { return items[0];}const firstPrice = firstItem([3, 4]);// ^?const firstName = firstItem(["Tea", "Cake"]);// ^?console.log(firstPrice, firstName);Line 1 says firstItem takes a list of T. Line 4 passes [3, 4], a number[], so T is number, and the query on line 5 shows const firstPrice: number. Line 6 passes text, so T is string. Line 8 prints 3 Tea.
Each argument that mentions T gives a candidate. When two arguments disagree, TypeScript doesn't join them the way it joins array elements. This is an error on purpose:
function listOf<T>(first: T, second: T): T[] { return [first, second];}const sizes = listOf("S", "M");// ^?const mixed = listOf(3, "4");listOf takes two values of the same type T. On line 4 both are text, so T is string and the query on line 5 shows string[]. On line 6, 3 gives the candidate number and "4" gives string. Neither covers the other, so TypeScript goes with the first, number, and reports the argument that doesn't fit: Argument of type 'string' is not assignable to parameter of type 'number'. Often that is the bug you want caught. When mixing is what you mean, write the type argument yourself, and nothing is inferred:
function listOf<T>(first: T, second: T): T[] { return [first, second];}const mixed = listOf<number | string>(3, "4");// ^?console.log(mixed);Line 4 says T is number | string, so both arguments fit, and the query on line 5 shows (string | number)[]. Line 6 prints the list with 3 and "4".
Sometimes an argument shouldn't help decide T at all. A size picker takes a list of sizes and a starting size, and the starting size should be one of the list, not a new size. TypeScript 5.4 added NoInfer for this. Press each button:
function pickSize<S extends string>(sizes: S[], initial: S) { return initial;}const size = pickSize(["S", "M", "L"], "XL");// ^?console.log(size);XL"S" | "M" | "L" | "XL"No errorsBoth arguments help decide S: the list gives "S", "M", and "L", and initial adds "XL". So S is all four, and the size that isn't in the list gets through with no error.
Without NoInfer, the starting size "XL" joins the candidates, so S becomes four sizes and the mistake gets through. With NoInfer, NoInfer<S> on line 1 tells TypeScript to use S for that parameter but not to learn S from it. Only the list decides, so line 4 gets an error. With a real size, "M", the error is gone. Every version prints the size it was given: types never change what runs.
NoInfer, only the list decides S. The second argument, "XL", is checked against S afterwards, and it doesn't fit.When inference gives up
Inference needs something to learn from. When there is too little, you get a type that is either too empty or too loose:
const filters = {};// ^?filters.size = "M";Line 1 makes an empty object, and the query on line 2 shows its type: {}, an object with no known properties. Line 3 adds a size, and TypeScript reports Property 'size' does not exist on type '{}'. The type was decided on line 1 and doesn't grow. Write the type you want: const filters: { size?: string } = {};.
const reply = JSON.parse('{ "price": 3 }');// ^?console.log(reply.prise); function emptyList<T>(): T[] { return [];}const list = emptyList();// ^?Line 1 reads a reply with JSON.parse. Its declaration says it gives back any, so the query on line 2 shows any: a type that turns checking off (any, unknown & never covers it). Line 3 misspells price as prise, and nothing complains; it prints undefined. Lines 5 to 7 declare a generic emptyList, and line 8 calls it with no arguments, so there is nothing to learn T from. TypeScript falls back to unknown, a type that allows nothing until you check it, and the query on line 9 shows unknown[].
Two cases look like giving up but aren't: a let with no value, and an empty array.
let total;total = 3;const doubled = total * 2;// ^?const items = [];items.push("Tea");console.log(items, doubled);// ^?Line 1 declares total with no value and no type. Instead of giving up, TypeScript follows what is stored in it: after line 2, total is a number, so the query on line 4 shows doubled is a number. Line 5's empty array works the same way: after push("Tea") on line 6, the query on line 8 shows items as string[]. Line 7 prints the list with "Tea", then 6. This is the checker following the code line by line, which Control flow analysis opens up.
The table below sums up these cases. Read each row as: what you write, what TypeScript infers, and what to write instead.
| What you write | What TypeScript infers | What to write instead |
|---|---|---|
const filters = {} | {}: an object with no known properties. | Write the type: const filters: { size?: string } = {}. |
JSON.parse(text) | any, because that is what JSON.parse says it gives back. | Write the type you expect, and check the data while the page runs. |
emptyList() with nothing to learn T from | unknown[]: TypeScript knows nothing about the items. | Pass the type argument: emptyList<string>(). |
const onClick = (event) => … on its own | An error: event would be any. | Write the parameter's type, or write the function where it is used. |
let total; then total = 3 | It follows the assignments: total is a number after line 2. | Nothing: this is inference working, not giving up. |
A settings object that lost its theme
A settings panel keeps its defaults in an object and passes them to a function. No types are written on the object, and the call fails. This is an error on purpose:
type Theme = "dark" | "light";type Settings = { theme: Theme; fontSize: number }; const defaults = { theme: "dark", fontSize: 16 };// ^? function applySettings(settings: Settings) { console.log(`${settings.theme}, ${settings.fontSize}px`);}applySettings(defaults);Lines 1 and 2 describe a Settings object whose theme is "dark" or "light". Line 4 makes the defaults, and the query on line 5 shows the cause: theme: string. The property widened, because nothing on line 4 says it is a theme. Line 10 gets Argument of type '{ theme: string; fontSize: number; }' is not assignable to parameter of type 'Settings': any string might not be a theme. The code still prints dark, 16px.
The fix is to give line 4 a context. satisfies does it while keeping the object's own type:
type Theme = "dark" | "light";type Settings = { theme: Theme; fontSize: number }; const defaults = { theme: "dark", fontSize: 16 } satisfies Settings;// ^? function applySettings(settings: Settings) { console.log(`${settings.theme}, ${settings.fontSize}px`);}applySettings(defaults);Line 4 ends with satisfies Settings (as const & satisfies covers it). Settings is now the context for the object, so "dark" keeps its exact type: the query on line 5 shows theme: "dark". Line 10 is fine. Writing const defaults: Settings would work too, and as const would keep every value exact.
const size = "M";let size = "M";const order = { size: "M" };const sizes = ["S", "M"];const sizes = ["S", "M"] as const;const size = "M"; let copy = size;const size: "M" = "M"; let copy = size;function getSize() { return "M"; }
Sort each line by the type its last name gets: exactly "M", or string. Each line is checked on its own.
Common mix-ups
- "Inference is TypeScript guessing." No. It follows fixed rules: the same code always gets the same types, and you can predict them.
- "A const always keeps its exact value." No. Its properties widen, its array elements widen, and a copy in a
letwidens. Only a written type oras conststops that. - "TypeScript looks at how I use a name later to pick its type." No. The type comes from the declaration and its place. A separate handler doesn't learn from the line that uses it. Only a
letwith no value and an empty array follow what comes next. - "Different values always make a union." No. Array elements and return statements do, but two arguments for the same
Tdon't: the second one gets an error. - "The best common type finds the shared parent class." No. It picks one of the candidates or joins them.
[new SaveButton(), new CancelButton()]isn't aControl[]unless you say so. - "NoInfer turns off checking." No. The argument is still checked; it just doesn't help decide the type.
Practice exercises
Guess first, then check: point at a name, or press Edit & run and add a // ^? line under it.
What type does size get on line 2?
const order = { size: "M", quantity: 2 };const size = order.size;const order = { size: "M", quantity: 2 };const size = order.size;// ^?order.size can be changed later, so its "M" widens to string, and size copies that type. The query on line 3 shows const size: string.
Some shoppers left no rating. What type does ratings get?
const ratings = [5, 4, null];const ratings = [5, 4, null];// ^?The candidates are number and null. Neither covers the other, so TypeScript joins them: (number | null)[].
Line 1 gets Parameter 'event' implicitly has an 'any' type. Fix it without changing what the code does. What type does event get then?
const onKey = (event) => { console.log(event.key);};window.addEventListener("keydown", onKey);window.addEventListener("keydown", (event) => { console.log(event.key); // ^?});Written where addEventListener("keydown", …) expects a handler, the function gets context, and event is a KeyboardEvent, as the query on line 3 shows. Writing (event: KeyboardEvent) on the separate const works too.
Line 6 gets an error. What type does current get in the starter? Then fix line 5 so line 6 is fine.
type Theme = "dark" | "light";function setTheme(theme: Theme) { console.log(theme); } const saved = "light";let current = saved;setTheme(current);type Theme = "dark" | "light";function setTheme(theme: Theme) { console.log(theme); } const saved = "light";let current: Theme = saved;setTheme(current);In the starter, copying saved into a let widens it, so current is a string and line 6 fails. let current: Theme = saved gives it context: "light" fits, and current can still change to another theme.
What does TypeScript infer for T on line 4? Then change the call so mixing is allowed.
function pair<T>(first: T, second: T) { return [first, second];}const prices = pair(3, "4");function pair<T>(first: T, second: T) { return [first, second];}const prices = pair<number | string>(3, "4");console.log(prices);3 and "4" give number and string. Neither covers the other, so T is number and "4" is reported. Writing pair<number | string> decides T yourself, and the call is fine.
The starter accepts "blue" though it isn't in the list. Add NoInfer so it gets an error. What does TypeScript infer for C then?
function pickColor<C extends string>(colors: C[], fallback: C) { return fallback;}const color = pickColor(["red", "green"], "blue");console.log(color);function pickColor<C extends string>(colors: C[], fallback: NoInfer<C>) { return fallback;}const color = pickColor(["red", "green"], "green");// ^?console.log(color);With NoInfer<C>, only the list decides C, so C is "red" | "green" and "blue" gets an error. A real color, "green", is fine, and the query on line 5 shows the type.
Check your understanding
For each question, ask where the type comes from: the value or the place. The quiz shows its code without the checker, so the types don't give the answers away.
Question 1 of 7What type does TypeScript give
sizehere?Read the code, then predictlet size = "M";Choose an answer to see the explanation.
Question 2 of 7What type does
copyget when a const is copied into a let?Read the code, then predictconst theme = "dark"; let copy = theme;Choose an answer to see the explanation.
Question 3 of 7What is the best common type of these discounts?
Read the code, then predictconst discounts = [5, null, 10];Choose an answer to see the explanation.
Question 4 of 7In
button.addEventListener("click", (event) => …), where doeseventget its type?Choose an answer to see the explanation.
Question 5 of 7Which line does TypeScript flag when two arguments give different types for T?
Read the code, then predictfunction listOf<T>(first: T, second: T): T[] { return [first, second]; } const mixed = listOf(3, "4");Choose an answer to see the explanation.
Question 6 of 7What does
NoInfer<S>on a parameter do?Choose an answer to see the explanation.
Question 7 of 7What does TypeScript say about adding a property to an empty object?
Read the code, then predictconst filters = {}; filters.size = "M";Choose an answer to see the explanation.
Key takeaways
- Inference follows fixed rules, in two directions: from the value, and from the place.
- Exact values widen where they can change: a
let, a property, an array element, a copy of aconst. A written type oras conststops it. - Several values get their best common type: a candidate that covers the rest, or the union of them all.
- A value written where a type is expected gets that type: callbacks, handlers, and typed objects.
- Type arguments come from the arguments. Disagreeing arguments get an error;
NoInferkeeps an argument from deciding. - With nothing to learn from, you get
{},any, orunknown. Write the type there.
Remember the one-liner.
A type comes from the value, widened if it can change, or from the place it's written, and generics fill in from their arguments.
Coming next: Control flow analysis. It shows how the checker follows your code line by line, which is how let total; became a number.