Type Annotations and Type Inference

8 min read

You've seen the : type syntax — explicit annotations on every line. That's only half the story. TypeScript is also constantly inferring types from values you assign, even when you don't write annotations. Knowing when to annotate and when to let inference do the work is the difference between TypeScript that feels heavy and TypeScript that feels effortless. This lesson covers both, plus three special types — any, unknown, and never — that beginners get tripped up by.

Annotation vs inference, side by side

Two ways to declare the same value:

// Explicit annotation — you write the type
const baseUrl: string = "https://staging.myapp.com";
 
// Inference — TypeScript reads the value and figures it out
const baseUrl2 = "https://staging.myapp.com";

Both produce a variable of type string. Hover over baseUrl2 in VS Code and you'll see const baseUrl2: string in the tooltip — TypeScript inferred it.

This is critical to understand: TypeScript is always tracking types, even on lines without annotations. The annotation is a way for you to declare intent and for the compiler to enforce it. When the value is unambiguous, the compiler can do the work itself.

When to annotate explicitly

Three places annotations almost always pay for themselves:

1. Function parameters. Always annotate. The compiler can't read your mind about what callers will pass.

function login(username: string, password: string) {
  return api.post("/login", { username, password });
}

Without annotations, username and password would be any (or a compile error under strict).

2. Function return types. Annotate for clarity and for safety — the annotation makes sure the function actually returns what its signature claims.

function getStagingUrl(): string {
  return "https://staging.myapp.com";
}

If the function ever accidentally returned a number, the compiler would catch it at the function definition rather than at every call site.

3. Variables where the inferred type would be wrong or unclear. When the initial value is broader than what you actually want to allow, annotate to narrow it.

const config: TestConfig = loadConfig();    // makes the contract explicit
let currentEnv: "dev" | "staging" | "prod" = "dev";  // annotation locks it down

When to let inference do the work

The compiler is good at simple cases. Annotating them is just noise.

const count = 5;                     // inferred: number
const name = "Alice";                // inferred: string
const browsers = ["Chrome", "Firefox"]; // inferred: string[]
const users = getUsers();            // inferred from getUsers's return type

Adding : number, : string, : string[], : User[] to those lines is technically correct and entirely redundant. Junior TypeScript code is full of these annotations; senior TypeScript code reads more like JavaScript with annotations only where they earn their place.

A useful rule: annotate at the boundaries — function signatures, exports, public APIs — and let inference handle the inside. Inside a function body, inference covers most variables.

Annotation vs inference at a glance

When to annotate, when to let TypeScript infer

Annotate explicitly

  • Function parameters: always

  • Function return types: usually — locks the contract

  • Variables where the value is broader than the intent

  • Empty arrays — let users: User[] = []

  • Public exports — module boundaries

Let inference do it

  • Simple literals — const count = 5

  • Return values of well-typed functions

  • Array literals with consistent types

  • Loop counters and short-lived locals

  • Anywhere annotations would just repeat the value

any — the escape hatch

any opts a value out of type checking entirely. It says: "trust me, do whatever, don't check anything."

const data: any = whatever();
data.foo.bar.baz();   // compiles — even if data is null
data();               // compiles — even if data isn't a function

any will never produce a type error, and that's exactly the problem. You've turned TypeScript back into JavaScript at that point. Avoid any wherever possible.

Legitimate uses are narrow:

  • Migrating a JavaScript file to TypeScript gradually. any lets you ship the conversion without typing every line on day one.
  • Working with a third-party library that ships no types. Increasingly rare — most libraries today either ship types or have @types/<lib> packages on DefinitelyTyped.

If you find yourself reaching for any to silence an error, pause. The error is usually pointing at a real bug.

unknown — the safe alternative

unknown is any's safer cousin. It accepts any value (like any) but won't let you use it without checking the type first. This is the right type for data you don't trust yet — typically the response body from an API, JSON loaded from a file, anything from outside your code.

const data: unknown = JSON.parse(rawText);
 
// data.toUpperCase();
// ❌ 'data' is of type 'unknown'.
 
if (typeof data === "string") {
  console.log(data.toUpperCase());   // ✅ TypeScript narrows it to string here
}
 
if (Array.isArray(data)) {
  console.log(data.length);          // ✅ narrowed to any[]
}

The typeof and Array.isArray checks are type guards — they tell TypeScript "inside this branch, the value is definitely this type." You'll meet them properly in chapter 6; for now, the takeaway is: prefer unknown to any whenever you accept untrusted input.

never — the impossible type

never represents a value that should never exist. You'll see it in three places:

  • Functions that don't return — they throw or loop forever.

    function fail(message: string): never {
      throw new Error(message);
    }
  • Exhaustiveness checks in switch statements, where you want the compiler to flag a missed case.

  • Conditional types that filter out branches (advanced — chapter 6).

You won't write : never often, but you'll see it in compiler error messages. When TypeScript says Type 'string' is not assignable to type 'never', it usually means an exhaustive switch is missing a case.

A QA-flavoured example

A small function that processes a test result. Notice how few annotations are needed once the input shape is declared:

type TestResult = { name: string; passed: boolean; duration: number };
 
function summarise(result: TestResult) {           // annotate the parameter
  const icon = result.passed ? "✅" : "❌";        // inferred: string
  const seconds = result.duration / 1000;          // inferred: number
  const message = `${result.name} ${icon} (${seconds}s)`; // inferred: string
  return message;                                  // return type inferred: string
}
 
console.log(summarise({ name: "Login", passed: true, duration: 1250 }));
// Login ✅ (1.25s)

One annotation at the boundary; everything inside is inferred. The compiler still type-checks every line — try changing result.passed ? "✅" : 1 and you'll see it complain that icon's type widened in a way the next line doesn't expect.

⚠️ Common mistakes

  • Annotating every single variable. Beginners often write const count: number = 5; const name: string = "Alice". It's not wrong, just noisy. Trust inference for trivial cases — your code will read better and refactors will be easier.
  • Reaching for any instead of fixing the real type issue. Every any is a hole in the safety net. If you need to suppress a type because you genuinely know better, prefer a narrow type assertion (value as User) over any — at least the assertion documents your intent.
  • Forgetting to use unknown for parsed JSON or API responses. JSON.parse() returns any by default, which means typos in field names sail through unchecked. Casting the result to unknown and narrowing through type guards (or a runtime validator) is the safe pattern. This is one of the highest-leverage habits in TypeScript test code.

🎯 Practice task

Refactor for inference and feel the difference. 20-30 minutes.

  1. In your ts-for-qa/src folder, create summary.ts.
  2. Write a function summarise that takes a parameter result annotated with the shape { name: string; passed: boolean; duration: number } and returns a one-line summary string. Annotate ONLY the parameter — let inference handle the rest.
  3. Hover over each const and the function's return value in VS Code. Confirm TypeScript correctly inferred the types.
  4. Call summarise with a sample object and console.log the result.
  5. Run with npx ts-node src/summary.ts. Confirm the output.
  6. Add an any and watch the safety net disappear. Add const broken: any = "hello"; console.log(broken.toUpperCase()); console.log(broken.fakeMethod());. Run it — TypeScript compiles both lines, but the second crashes at runtime. That's any in action.
  7. Now use unknown. Change to const broken: unknown = "hello"; console.log(broken.toUpperCase()); — read the compile error. Add a typeof guard around it and confirm the error goes away.
  8. Stretch: load a JSON fixture (a file with { "name": "Login test", "passed": true, "duration": 1250 }), parse it, treat the result as unknown, narrow it with a type guard before passing it to summarise. This is the safe pattern for every real test fixture you'll ever load.

You've now seen the full type-system workflow: annotate at boundaries, infer in the middle, narrow at the edges. The next chapter deepens the toolbox with the core types — primitives, arrays, tuples, and unions.

// tip to track lessons you complete and pick up where you left off across devices.