cf.completefrontend
Open lab My progress
THE TYPESCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Predict wideningSay when a value keeps its exact type and when it widens, and stop it with a type or as const.
  • 02
    Follow 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.
  • 03
    Steer type argumentsPredict how TypeScript fills in a generic's type argument, and use NoInfer or 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:

Four rules in three linesPop out in the code editor (opens in a new tab)TypeScript
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 name as string, 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 shows item is a cart item, though its brackets say nothing about it. It got its type from the place it is written: the callback of cart.reduce. That is contextual typing.
  • The query on line 4 shows const total: number. reduce is a generic function (one with a type parameter, a placeholder for a type), and TypeScript filled in that placeholder from the starting value 0. That is inferring a type argument.

Line 6 prints 7. The rules work in two directions, which the picture below shows:

Left, from the value: let theme = "dark" leads up to the type theme: string. Right, from the place: a click handler leads down to the type event: PointerEvent.From the valuelet theme = "dark"theme: stringthe value decidesFrom the placea "click" handlerevent: PointerEventthe place decides
Two directions. On the left, the value "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.

Real-life analogyLabelling jars in a kitchen

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 let holding "dark" gets the type string
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
The main idea

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.

Four ways TypeScript infers a type
The ruleWhere it looksExampleThe type
Declarations and wideningThe starting value, and whether the name can change.let theme = "dark"string
The best common typeEvery value in an array or every return statement.[5, null, 10](number | null)[]
Contextual typingThe place the value is written: a typed variable, a parameter, a callback.addEventListener("click", (event) => …)event: PointerEvent
Type argument inferenceThe 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:

What widens and what doesn'tPop out in the code editor (opens in a new tab)TypeScript
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:

Playground: copy a theme into a let
A theme copied into a letPop out in the code editor (opens in a new tab)TypeScript
type Theme = "dark" | "light";function setTheme(theme: Theme) { console.log(`Theme: ${theme}`); }let theme = "dark";let copy = theme;//  ^?setTheme(copy);
Console outputTheme: dark
What TypeScript infers
copystring
TypeScript1 error: line 6
Try it yourself

theme 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.

TypeScript checks the code for real each time. The buttons change only line 3. Reset goes back to let.

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:

What a function's return type widens toPop out in the code editor (opens in a new tab)TypeScript
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:

Arrays of numbers, nulls, and textPop out in the code editor (opens in a new tab)TypeScript
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)[].

The values 5, null, and 10 each give a candidate type: number, null, and number. Arrows join the candidates into one type, an array of number or null.valuescandidates5numbernullnull10number(number | null)[]the best common type
Each element of [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:

A cart where one item has no pricePop out in the code editor (opens in a new tab)TypeScript
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:

Buttons that share a parent classPop out in the code editor (opens in a new tab)TypeScript
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):

Playground: where the handler is written
An event handler for a buttonPop out in the code editor (opens in a new tab)TypeScript
const button = document.createElement("button");button.addEventListener("click", (event) => {  console.log(event.button);});
The type of eventPointerEvent
What TypeScript says
ErrorsNo errors
Try it yourself

The 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.

TypeScript checks the code for real each time. Point at event to see its type. Reset goes back to Inline, click.

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.

The event name "click" leads to the type a click handler receives, PointerEvent, which leads to the parameter event in the arrow function."click"the event namePointerEventwhat a clickhandler receives(event) =>your parameterthe type flows from the place into the function
TypeScript reads the event name first. The browser's types say what a "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:

A settings object gives its callback a typePop out in the code editor (opens in a new tab)TypeScript
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:

T filled in from the listPop out in the code editor (opens in a new tab)TypeScript
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:

Two arguments, two candidates for T (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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:

A type argument written by handPop out in the code editor (opens in a new tab)TypeScript
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:

Playground: let only the list decide
A size picker with a starting sizePop out in the code editor (opens in a new tab)TypeScript
function pickSize<S extends string>(sizes: S[], initial: S) {  return initial;}const size = pickSize(["S", "M", "L"], "XL");//    ^?console.log(size);
Console outputXL
What TypeScript infers
size"S" | "M" | "L" | "XL"
TypeScriptNo errors
Try it yourself

Both 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.

TypeScript checks the code for real each time. The buttons change line 1 and line 4. Reset goes back to Without NoInfer.

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.

The list ["S", "M", "L"] has an arrow to the type S, which becomes "S" | "M" | "L". The argument "XL", marked NoInfer, is only checked against S, and fails.["S", "M", "L"]decides SS = "S" | "M" | "L""XL"NoInfer: only checked"XL" isn't one of themerror 2345
With 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:

An empty object stays empty (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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 } = {};.

any from JSON.parse, unknown from an empty generic callPop out in the code editor (opens in a new tab)TypeScript
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.

A let and an array that learn from what comes nextPop out in the code editor (opens in a new tab)TypeScript
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.

When inference has too little to learn from
What you writeWhat TypeScript infersWhat 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 fromunknown[]: TypeScript knows nothing about the items.Pass the type argument: emptyList<string>().
const onClick = (event) => … on its ownAn error: event would be any.Write the parameter's type, or write the function where it is used.
let total; then total = 3It 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:

Default settings with a widened theme (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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:

The same defaults, checked with satisfiesPop out in the code editor (opens in a new tab)TypeScript
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.

Exact or widened?
  • 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"; }
Try it yourself
0 of 8 correct

Sort each line by the type its last name gets: exactly "M", or string. Each line is checked on its own.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 let widens. Only a written type or as const stops 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 let with 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 T don'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 a Control[] 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.

Exercise 1 · Warm-upPredict a property's type

What type does size get on line 2?

Starter codePop out in the code editor (opens in a new tab)TypeScript
const order = { size: "M", quantity: 2 };const size = order.size;

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind the best common type

    Some shoppers left no rating. What type does ratings get?

    Starter codePop out in the code editor (opens in a new tab)TypeScript
    const ratings = [5, 4, null];

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeGive a handler its context back

      Line 1 gets Parameter 'event' implicitly has an 'any' type. Fix it without changing what the code does. What type does event get then?

      Starter codePop out in the code editor (opens in a new tab)TypeScript
      const onKey = (event) => {  console.log(event.key);};window.addEventListener("keydown", onKey);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeKeep a theme exact

        Line 6 gets an error. What type does current get in the starter? Then fix line 5 so line 6 is fine.

        Starter codePop out in the code editor (opens in a new tab)TypeScript
        type Theme = "dark" | "light";function setTheme(theme: Theme) { console.log(theme); } const saved = "light";let current = saved;setTheme(current);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeTwo arguments, one T

          What does TypeScript infer for T on line 4? Then change the call so mixing is allowed.

          Starter codePop out in the code editor (opens in a new tab)TypeScript
          function pair<T>(first: T, second: T) {  return [first, second];}const prices = pair(3, "4");

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeStop the fallback from deciding

            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?

            Starter codePop out in the code editor (opens in a new tab)TypeScript
            function pickColor<C extends string>(colors: C[], fallback: C) {  return fallback;}const color = pickColor(["red", "green"], "blue");console.log(color);

            Answer, then press Check. Spacing and letter case don’t matter.

              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.

              The How inference works quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What type does TypeScript give size here?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                let size = "M";

                Choose an answer to see the explanation.

              2. Question 2 of 7What type does copy get when a const is copied into a let?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const theme = "dark";
                let copy = theme;

                Choose an answer to see the explanation.

              3. Question 3 of 7What is the best common type of these discounts?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const discounts = [5, null, 10];

                Choose an answer to see the explanation.

              4. Question 4 of 7In button.addEventListener("click", (event) => …), where does event get its type?

                Choose an answer to see the explanation.

              5. Question 5 of 7Which line does TypeScript flag when two arguments give different types for T?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                function listOf<T>(first: T, second: T): T[] {
                  return [first, second];
                }
                const mixed = listOf(3, "4");

                Choose an answer to see the explanation.

              6. Question 6 of 7What does NoInfer<S> on a parameter do?

                Choose an answer to see the explanation.

              7. Question 7 of 7What does TypeScript say about adding a property to an empty object?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                const 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 a const. A written type or as const stops 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; NoInfer keeps an argument from deciding.
              • With nothing to learn from, you get {}, any, or unknown. 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.

              CompleteFrontend Clear concepts. Working examples.