Lesson 3 of 20

Type Annotations

An Annotation Is a Promise You Make

A type annotation is a colon and a type, written after a name. let city: string tells the compiler that whatever ends up in city, it will be a string, and asks it to reject anything else. The syntax is the same everywhere it appears: after a variable name, after a function parameter, after a function's parameter list for the return type.

It is worth being precise about what this does, because the mental model matters later. An annotation does not convert anything, and it does not check anything at runtime. It is a claim you are making about a value, and the compiler's job is to look at every place that value is used and confirm that nothing contradicts the claim. When it finds a contradiction it reports an error and stops; it never tries to fix your value.

Annotations are also the documentation that cannot go stale. A comment saying "pass the user id here" is a hope. userId: number is enforced, so it stays true, and your editor can act on it.

Example
// The shape of every annotation: name, colon, type
let city: string = "Chennai";
let age: number = 21;
let isEnrolled: boolean = true;

city = "Kochi";   // OK
city = 400001;    // ERROR: Type 'number' is not assignable to type 'string'.

// Function parameters and return type
function seatsLeft(total: number, booked: number): number {
  return total - booked;
}

// Arrow functions look the same
const percent = (part: number, whole: number): number => (part / whole) * 100;

Inference: Most Types Write Themselves

TypeScript works out types on its own wherever it can, and this is not a lesser fallback — it is the normal way to write TypeScript. Assign a value and the compiler reads its type from that value. Writing let age: number = 21 gives you nothing that let age = 21 did not already give you, and it is one more thing to keep in sync when the code changes.

There is a subtlety in how inference behaves with let versus const. Because a let variable can be reassigned, TypeScript widens the type to the general one: let status = "active" is inferred as string. A const can never be reassigned, so TypeScript keeps the exact value as the type: const status = "active" has the literal type "active". That difference becomes important the moment you work with unions of specific strings, which is a very common pattern in real code.

The practical rule is: let inference do the middle of your program, and annotate the edges. Annotate function parameters, function returns that cross a module boundary, and anything whose type cannot be seen from the initial value. Everywhere else, leave it alone and hover over the variable in your editor when you want to know what the compiler decided.

Example
let count = 10;              // inferred: number
let label = "total";         // inferred: string (widened, because let)
const label2 = "total";      // inferred: "total" (the literal type)

const prices = [199, 499, 899];   // inferred: number[]
const user = { name: "Rahul", age: 21 };  // { name: string; age: number }

// Inference follows through calls, so you rarely annotate results
const names = user.name.split("");   // string[]
const first = prices[0];             // number

// Where the literal type matters:
type Status = "active" | "blocked";

let s = "active";      // string
const c = "active";     // "active"

let a: Status = c;      // OK — "active" fits Status
let b: Status = s;      // ERROR: Type 'string' is not assignable to type 'Status'.
Notes
  • That last error confuses almost everyone the first time. TypeScript is not being difficult: s is a let, so it could hold any string by the time it is read, and most strings are not a valid Status. Declare it const, or annotate it let s: Status = "active".

Where You Genuinely Must Annotate

Inference needs a value to read. In three situations there is no value to read from, and those are exactly the places an annotation is required.

The first and most important is function parameters. When TypeScript compiles a function it has no idea who will call it, so a parameter with no annotation has no type at all. Under strict this is an error — Parameter 'x' implicitly has an 'any' type — from the noImplicitAny check. Without strict it silently becomes any, and every check inside that function quietly disappears. This is the number one reason to keep strict mode on.

The second is a variable declared before it is given a value. let result; has nothing to infer from. The third is an empty array or an empty object literal: const tags = [] tells the compiler nothing about what will go into it, so annotate it as string[] and get real checking on every push.

The important exception is contextual typing. When a function is written in a position where its type is already known — a callback passed to map, an event handler assigned to a typed property — TypeScript infers the parameters from that context, and annotating them again is noise.

Example
// 1. Parameters: no annotation means no type
function double(n) { return n * 2; }
// ERROR under strict: Parameter 'n' implicitly has an 'any' type.

function doubleOk(n: number): number { return n * 2; }

// 2. Declared now, assigned later
let result: string;
if (Math.random() > 0.5) result = "heads";
else result = "tails";

// 3. Empty collections
const tags: string[] = [];
tags.push("typescript");
tags.push(42);        // ERROR — and this only works because you annotated

// The exception: contextual typing. Do NOT annotate here.
const marks = [88, 74, 91];
const passed = marks.filter(m => m >= 80);   // m is already known to be number
Notes
  • Annotating a callback parameter that TypeScript already knows is harmless but pointless, and it becomes actively harmful when the surrounding type changes — you now have two places to update instead of one.

Return Types: Infer or Declare?

TypeScript can infer a function's return type from its return statements, so an annotation there is optional. Whether to write one is a genuine judgement call, and both sides have a real argument.

Annotating the return type turns the function into a contract that is checked inside the function. If you declare : number and a branch accidentally returns undefined, the error appears on that branch, where the mistake is. Without the annotation, the return type quietly becomes number | undefined, the function still compiles, and the error surfaces somewhere else entirely — possibly in a different file, possibly not at all. For exported functions, and for anything with several return paths, write the annotation.

The counter-argument applies to small internal helpers, where the inferred type is obvious from one glance and an annotation is just repetition. A reasonable habit: annotate anything exported from a module, and let short local functions infer. Two return types deserve a special note. void means "this function returns nothing useful", which is different from returning undefined on purpose. never means the function does not finish at all — it always throws, or loops forever.

Example
// Inferred: number | undefined, and nothing here looks wrong
function findMark(marks: number[], subject: string) {
  if (subject === "maths") return marks[0];
  // forgot the rest — falls through and returns undefined
}

// Annotated: the error lands inside the function, where the bug is
function findMarkStrict(marks: number[], subject: string): number {
  if (subject === "maths") return marks[0];
  // ERROR: Function lacks ending return statement and return type
  //        does not include 'undefined'.
}

// void — returns nothing meaningful
function logError(message: string): void {
  console.error(message);
}

// never — never finishes normally
function fail(message: string): never {
  throw new Error(message);
}

Annotating Objects, and When to Stop

You can describe an object's shape inline, between braces, listing each property with its type. Properties are separated by semicolons when written on one line. A ? after a property name marks it optional, which means the property may be absent altogether — and under strict, TypeScript then forces you to check before using it.

Inline object annotations are fine for a parameter used in one place. They stop being fine the moment the same shape appears twice, because now the description of a user lives in two files and the two copies will drift apart. At that point, move it into a named interface or type. Those are the next lessons; the rule of thumb is simple — write it inline once, name it on the second use.

Optional properties are where beginners meet strictNullChecks for the first time, and it is worth understanding rather than working around. last?: string means the value is string | undefined. Calling person.last.toUpperCase() is an error not because TypeScript is being pedantic, but because that is precisely the line that would throw Cannot read properties of undefined at runtime. Check it first and the error disappears.

Example
// Inline object annotation
function printStudent(student: { name: string; roll: number }) {
  console.log(`${student.roll}: ${student.name}`);
}

printStudent({ name: "Meera", roll: 118 });

// Optional property
function fullName(person: { first: string; last?: string }): string {
  // person.last.toUpperCase()
  // ERROR: 'person.last' is possibly 'undefined'.

  return person.last ? `${person.first} ${person.last}` : person.first;
}

// Nested shapes work the same way, but get unreadable fast —
// this is the point where you should reach for an interface instead.
function ship(order: {
  id: string;
  address: { city: string; pincode: string };
  items: { sku: string; qty: number }[];
}) {
  console.log(order.address.pincode, order.items.length);
}
Notes
  • Beware the capitalised versions: String, Number and Boolean are the wrapper object types, not the primitives. let s: String compiles but will not fit anywhere expecting string. Always use the lower-case names.
  • JSON.parse() returns any, which switches off checking for everything derived from it. Writing const user: User = JSON.parse(text) silences the compiler without checking a single field — the same trap as the API response in Lesson 1.
Ask AI