TypeScript Type Annotations

A type annotation is the piece of syntax you add to your code — a colon followed by a type — that tells the TypeScript compiler exactly what kind of value a variable, function parameter, or function return is supposed to hold. They are the most basic building block of TypeScript’s type system: everything else (interfaces, generics, unions) is really just richer content you can put after that colon. Annotations matter because they let the compiler catch mistakes — passing a string where a number belongs, forgetting a property, calling a method that doesn’t exist — before your code ever runs.

This lesson focuses purely on annotations: where you’re allowed to write them, what they look like, when TypeScript actually needs them versus when it can figure things out on its own, and the mistakes beginners commonly make when first using them.

Overview / How it works

In plain JavaScript, a variable can hold any value at any time, and the engine doesn’t check anything until code actually runs. TypeScript adds a static type system on top of JavaScript syntax: you can attach a type to a declaration using a colon, and the compiler verifies, before your code ever runs, that every subsequent use of that variable is consistent with the declared type.

The general form is always the same: a colon after the name, followed by the type.

Two things are essential to understand about how this works under the hood:

1. Type annotations are not required everywhere — TypeScript infers types when it can. If you write let age = 34; without any annotation, TypeScript looks at the initializer (34) and infers that age has type number, exactly as if you had written let age: number = 34; yourself. This is called type inference. Annotations are most valuable in three situations: function parameters (which TypeScript cannot infer from a call site alone), variables declared without an initial value, and object shapes you want to define and reuse (via interface or type).

2. TypeScript uses structural typing. When checking whether a value matches an annotated type, TypeScript doesn’t care about names or where a type was declared — it only cares about shape. If an object has all the properties a type requires, with compatible types, it satisfies that type, regardless of how the object was created. This is different from languages like Java or C#, which use nominal typing (where a class must explicitly declare that it implements an interface).

3. Annotations disappear at runtime. The TypeScript compiler performs type erasure: after checking your code against every annotation, it strips all type syntax out and emits plain JavaScript. The compiled output has no knowledge of number, string, or any interface — annotations exist purely to help the compiler (and you) catch bugs during development, not to change what runs.

Syntax

let variableName: Type = value;

function functionName(param: Type): ReturnType {
  // ...
}
Position Example Notes
Variable let count: number; Declares the type before any value is assigned
Function parameter function f(x: number) {} Almost always required — TypeScript can’t infer parameter types from the function body
Function return type function f(): string {} Optional — TypeScript infers it from the return statements, but writing it catches mistakes early
Array let names: string[]; Also writable as Array<string>
Tuple let pair: [number, string]; A fixed-length array with a specific type per position
Object shape interface User { id: number; } Defines a reusable named type instead of annotating inline
Optional property/param name?: string Adds undefined as an allowed value

Examples

Example 1: Annotating variables of different kinds

let age: number = 34;
let username: string = "grace";
let isActive: boolean = true;
let tags: string[] = ["ts", "js", "web"];
let coords: [number, number] = [10, 20];

console.log(age, username, isActive, tags, coords);

Output:

34 grace true [ 'ts', 'js', 'web' ] [ 10, 20 ]

Each variable’s annotation restricts what can ever be assigned to it afterward. tags can only ever hold an array of strings, and coords is a tuple — exactly two numbers, in that order — not just any number array.

Example 2: Annotating function parameters and return types

function calculateArea(width: number, height: number): number {
  return width * height;
}

function greet(name: string, excited?: boolean): string {
  return excited ? `HELLO, ${name.toUpperCase()}!` : `Hello, ${name}.`;
}

console.log(calculateArea(4, 5));
console.log(greet("Sam"));
console.log(greet("Sam", true));

Output:

20
Hello, Sam.
HELLO, SAM!

width and height must be annotated because TypeScript has no way to guess what type a parameter should accept just from how the function body uses it. The : number return annotation on calculateArea is technically optional here (TypeScript would infer number from the return statement), but writing it explicitly means the compiler will flag an error immediately if a future edit accidentally returns something else. The excited?: boolean parameter is optional, so it can be omitted entirely, as in the second call.

Example 3: Annotating object shapes with an interface

interface Product {
  id: number;
  name: string;
  price: number;
  inStock?: boolean;
}

function formatProduct(product: Product): string {
  const stockLabel = product.inStock === false ? "out of stock" : "in stock";
  return `${product.name} - $${product.price.toFixed(2)} (${stockLabel})`;
}

const products: Product[] = [
  { id: 1, name: "Keyboard", price: 49.99, inStock: true },
  { id: 2, name: "Monitor", price: 199.5, inStock: false },
  { id: 3, name: "Mouse", price: 19.99 },
];

for (const product of products) {
  console.log(formatProduct(product));
}

Output:

Keyboard - $49.99 (in stock)
Monitor - $199.50 (out of stock)
Mouse - $19.99 (in stock)

Instead of repeating an inline object type annotation on every variable, we define the shape once as Product and reuse it for the parameter type and the array element type (Product[]). Note that the third product omits inStock entirely — that’s allowed because it’s marked optional with ?, and structural typing means any object with at least the required id, name, and price properties (with matching types) satisfies Product.

How it works step by step / Under the hood

When the compiler encounters a type annotation, it performs a few distinct steps:

1. Parse the annotation into an internal type representation (a primitive, an object type, a union, and so on).

2. Check every assignment or usage against that type. For a variable, this means every future assignment must produce a value assignable to the declared type. For a function parameter, every call site must pass an argument assignable to that parameter’s type.

3. Use structural comparison for object types: to check whether a value is assignable to an interface, TypeScript verifies the value has all required properties with compatible types — it never checks class names or declaration sites.

4. Erase all annotations during emit. Once type checking finishes successfully (or you ignore the errors and force output), the compiler generates plain JavaScript with every : Type annotation, interface, and type-only construct stripped away. If you ran the calculateArea example through tsc, the emitted JavaScript would just be function calculateArea(width, height) { return width * height; } — no trace of number remains, because JavaScript itself has no concept of static types.

This is why type annotations can never be used to validate data coming from outside your program (an API response, user input, JSON.parse output) — by the time that data exists, there’s no type information left in the running program to check it against. Annotations are a compile-time-only safety net.

Common Mistakes

Mistake 1: Reassigning a variable to a value of the wrong type.

let count: number = 5;
count = "six";
// Error: Type 'string' is not assignable to type 'number'.

Once count is annotated as number, TypeScript enforces that type for every later assignment, not just the initial one. The fix is to assign a value of the correct type:

let count: number = 5;
count = 6;
console.log(count);

Output:

6

Mistake 2: A function’s return type annotation doesn’t match every code path.

function getLabel(value: number): string {
  if (value > 0) {
    return "positive";
  }
  // Error: Function lacks ending return statement and return type does not include 'undefined'.
}

Because the annotation promises a string is always returned, TypeScript checks every branch — and the implicit fall-through when value <= 0 returns undefined, which isn't a string. Add a return for every path:

function getLabel(value: number): string {
  if (value > 0) {
    return "positive";
  }
  return "non-positive";
}

console.log(getLabel(5));
console.log(getLabel(-2));

Output:

positive
non-positive

Mistake 3: Object literals with extra properties (excess property checks).

interface Point {
  x: number;
  y: number;
}

const p: Point = { x: 1, y: 2, z: 3 };
// Error: Object literal may only specify known properties, and 'z' does not exist in type 'Point'.

TypeScript performs a stricter check specifically for object literals assigned directly to an annotated type: any property not listed in the type is flagged, since it's almost always a typo or a misunderstanding of the shape. Remove the extra property or add it to the interface if it's intentional:

interface Point {
  x: number;
  y: number;
}

const p: Point = { x: 1, y: 2 };
console.log(p);

Output:

{ x: 1, y: 2 }

Best Practices

  • Let inference handle simple variable declarations with an obvious initializer (let total = 0;) — only annotate when the initial value doesn't make the intent clear or when there is no initial value at all.
  • Always annotate function parameters explicitly; TypeScript cannot infer them, and unannotated parameters silently become any unless noImplicitAny is enabled (it is, under strict).
  • Annotate function return types on exported or public functions, even when inference would work — it documents intent and stops an accidental change in the function body from silently changing the return type for every caller.
  • Prefer interface or type definitions over long inline object annotations once a shape is used more than once.
  • Use optional properties (?) rather than unions with undefined everywhere, unless you specifically need to require the key to be present with an explicit undefined value.
  • Never use a type annotation to "convince" the compiler that unchecked external data (API responses, form input) is safe — validate that data at runtime, since annotations have no effect once the program is running.

Practice Exercises

  • Write a function formatTemperature that takes a celsius: number parameter and returns a string like "25°C is 77°F". Annotate both the parameter and the return type.
  • Define an interface Task with id: number, title: string, and an optional done?: boolean. Create an array of at least three tasks typed as Task[], then write a function that logs only the tasks where done is not true.
  • Take this unannotated variable declaration — let value = 10; — and predict what type TypeScript infers for it. Then try reassigning it to a string and explain, in your own words, what error you'd expect and why.

Summary

  • A type annotation is a colon followed by a type, written after a variable name, function parameter, or function return position.
  • TypeScript infers types automatically when it can (e.g. from an initializer), so annotations aren't required everywhere — they're essential for function parameters and helpful for return types and reusable object shapes.
  • TypeScript uses structural typing: a value matches a type if its shape is compatible, regardless of how or where it was created.
  • Object literals assigned directly to an annotated type undergo excess property checks, catching typos that structural typing alone would miss.
  • All annotations are erased at compile time — the emitted JavaScript contains no type information, so annotations can never validate data at runtime.