Type Inference and Annotations in TypeScript: When Do You Write the Type Yourself?
How TypeScript infers types on its own, the widening difference between let and const, why parameters get annotations, and when you should write a return type explicitly, with examples.
Version note: The code in this post targets TypeScript 7.0 and Node 26.
Predict First
We're starting this post with almost no annotations at all. Look at these four variables:
let city = "Ankara";
const country = "Turkey";
let count = 0;
city = "Izmir";
count = "ten";
Two questions. First: is there a line in this code that errors, and if so, which one? The second is subtler: does TypeScript infer the same type for city and country? Both start with a string, but one is let and the other is const. Make your guesses; both answers come a few sections later.
Type Inference: TypeScript Works Out the Type Itself
In the environment we set up in the previous post, we mentioned one editor feature: hover over a variable and it shows the type TypeScript found for it. Now we're going to use that feature. Type this code into your editor and hover over each variable in turn:
const user = { name: "Ada", age: 36 };
// { name: string; age: number; }
const scores = [90, 85, 70];
// number[]
const tags = ["typescript", 2026];
// (string | number)[]
function getTotal(price: number, quantity: number) {
return price * quantity;
}
const total = getTotal(100, 2);
// number
We didn't write a type on any of them, yet TypeScript knows them all. It worked out on its own that the user object holds a string and a number, that scores is a number array, and that tags is an array that can hold both strings and numbers. The vertical bar in that last one is a union; it means "string or number", and we'll look at it in detail in a separate post. We didn't write a return type for getTotal either: since multiplying two numbers gives a number, it inferred that the function returns a number, and therefore that total is a number too.
This is called type inference. TypeScript infers a variable's type from the first value you give it, and from then on expects the variable to stick to that type. That's exactly why the count = "ten" line in the opening example errors. Because count started with 0, TypeScript had inferred its type as number:
error TS2322: Type 'string' is not assignable to type 'number'.
Here's the mental model: TypeScript is like a careful colleague who reads the code for you and asks, "this variable was a number from the start, so why is it text now?" You don't have to tell it everything one by one; it figures most of it out by looking at the values.
The Difference Between let and const: Widening
Now for the second question from the opening. Hover over city and country and you see different things:
let city = "Ankara";
// let city: string
const country = "Turkey";
// const country: "Turkey"
The type of city is string, but the type of country is "Turkey". That second one is a literal type: not "any string", but a type that accepts exactly the value "Turkey". The logic is simple. A variable declared with const can never be reassigned, so TypeScript knows its value for certain. A variable declared with let can be given a different string later, so TypeScript widens the type from "Ankara" to string. TypeScript calls this widening. It's also why the city = "Izmir" line compiles without trouble.
The difference looks small, but it surfaces when you meet a function that expects specific values:
function setTheme(value: "dark" | "light") {
console.log(`Theme: ${value}`);
}
const theme = "dark";
let mode = "dark";
setTheme(theme);
// Error: Argument of type 'string' is not assignable to parameter of type '"dark" | "light"'.
setTheme(mode);
Because theme is a const, its type is "dark" and it fits the function. mode, however, is a let; its type has widened to string, and not every string is "dark" or "light". Literal types and unions get their own post; for now it's enough to keep this difference between let and const in mind.
One more thing to watch: const only fixes the variable, not the object inside it. If you write const settings = { mode: "dark" }, the type of settings.mode is still string, because, as you know from the JavaScript series, an object's property can be changed later.
Writing Annotations
Inference isn't always enough. When you want to state the type yourself, you write a colon and the type after the variable name. This is called a type annotation:
let username: string = "ada";
let retries: number;
retries = 3;
// Error: Type 'number' is not assignable to type 'string'.
username = 42;
The retries example is a case where the annotation genuinely helps: at the moment you declare the variable there's no value yet; the value comes later. Without an initial value, TypeScript has nothing to look at, so you tell it the type. The annotation on the username line, on the other hand, is actually unnecessary; we'll talk about that shortly.
Function Parameters: Where Annotations Really Belong
The most important place where inference doesn't kick in by itself is function parameters:
// Error: Parameter 'value' implicitly has an 'any' type.
function double(value) {
return value * 2;
}
You might think, "can't TypeScript look at how the function is called and work it out?" It can't, because a function is defined once and may be called from a hundred places with a hundred different values. TypeScript lets types flow from the definition to the call, not the other way round. When a parameter's type isn't written, it has only one option left: any, which means "it could be anything, check nothing." In strict mode that doesn't happen silently; TypeScript reports an error. We'll talk about why any is dangerous in the next post. For now the rule is: you annotate function parameters; they are the contract a function makes with the outside world.
There's one exception. When you pass a function to another function as a callback, TypeScript infers the parameter's type from the context:
const prices = [100, 250, 40];
const doubled = prices.map((price) => price * 2);
// Error: Property 'toUpperCase' does not exist on type 'number'.
const labels = prices.map((price) => price.toUpperCase());
We didn't annotate price here, but TypeScript knows that prices is a number array and that map will hand that array's elements to the callback. So price is a number, and the toUpperCase call gets caught. This is called contextual typing; in callbacks you usually don't need to write annotations.
Return Types: When Should You Write Them?
Most of the time TypeScript infers the return type correctly. But look at this function:
function getDiscount(total: number) {
if (total > 1000) {
return 0.1;
}
return "none";
}
// Error: The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
const finalPrice = 1200 * (1 - getDiscount(1200));
Whoever wrote the function actually wanted to always return a number; the second return is a slip. TypeScript doesn't object, because from its point of view the function is perfectly valid: the return type is inferred as "0.1 or "none"". The error only blows up on the line where you use the function, and the message doesn't tell you where the real problem is. In a real project that line could be in another file, in someone else's code.
Write your intent as an annotation and the error moves to the right place:
function getDiscount(total: number): number {
if (total > 1000) {
return 0.1;
}
// Error: Type 'string' is not assignable to type 'number'.
return "none";
}
Now TypeScript points straight at the offending line. The practical rule: in small, local functions, leave the return type to inference. In functions other files use, in functions with several return branches, and anywhere the return type is a contract, write it out. That way someone changing the inside of the function can't break the code that uses it without noticing.
Unnecessary and Harmful Annotations
People new to TypeScript usually put a type on everything:
const firstName: string = "Ada";
const price: number = 100;
const isActive: boolean = true;
These aren't wrong, but they don't add information either; the value already tells you the type. And on the first line you actually lost something: without the annotation, the type of firstName would have been "Ada"; now it's string. An annotation overrides inference and sometimes makes you settle for a wider type than you'd get otherwise.
Worse is when an annotation that's wider than needed erases information you had:
const user: object = { name: "Ada", age: 36 };
// Error: Property 'name' does not exist on type 'object'.
console.log(user.name);
object says "this is an object" but not what's inside it. TypeScript trusts the annotation and forgets about name. Delete the annotation and inference would give you a far more accurate type.
The balance to keep in mind is this: write annotations at the boundaries, meaning function parameters, the return types of functions exposed to the outside, and variables that have no initial value or start out empty (like const cart: string[] = []). Leave local variables inside functions to inference.
Common Pitfalls
The first pitfall is passing a variable declared with let to a place that expects specific literal values. As you saw in the setTheme(mode) example above, let widens the type. If the value won't change, use const.
The second is widening an annotation to silence an error. Write object, or worse, any, and the error disappears, but so does the information TypeScript was giving you. When you see an error, try narrowing the type first, not widening it.
The third is assuming the type the editor shows without checking. Everywhere you think "this must be a string", hover and look. Seeing a variable's type instead of guessing it is the fastest way to learn TypeScript.
A Small Experiment
★☆☆ Predict, then check by hovering in your editor: which types does TypeScript infer for const port = 8080;, let host = "localhost"; and const server = { host: "localhost", port: 8080 };?
★★☆ Does it compile? After writing const ids = [1, 2, 3];, add the line ids.push("4");. If it errors, read the message and think about which value the array's type was inferred from.
★★★ Rewrite the getDiscount function with a return type so that it returns 0 for totals under 1000. Then delete the annotation and hover over getDiscount: which return type does TypeScript infer now?
So far we've worked with familiar types like string, number and boolean, but we also met any and set it aside as dangerous. In the next post we'll look at all the basic types, the place of null and undefined in strict mode, and any's safe sibling unknown, along with void and never.