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 downWhen 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 typeAdding : 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 functionany 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.
anylets 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
switchstatements, 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
anyinstead of fixing the real type issue. Everyanyis 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) overany— at least the assertion documents your intent. - Forgetting to use
unknownfor parsed JSON or API responses.JSON.parse()returnsanyby default, which means typos in field names sail through unchecked. Casting the result tounknownand 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.
- In your
ts-for-qa/srcfolder, createsummary.ts. - Write a function
summarisethat takes a parameterresultannotated 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. - Hover over each
constand the function's return value in VS Code. Confirm TypeScript correctly inferred the types. - Call
summarisewith a sample object andconsole.logthe result. - Run with
npx ts-node src/summary.ts. Confirm the output. - Add an
anyand watch the safety net disappear. Addconst broken: any = "hello"; console.log(broken.toUpperCase()); console.log(broken.fakeMethod());. Run it — TypeScript compiles both lines, but the second crashes at runtime. That'sanyin action. - Now use
unknown. Change toconst broken: unknown = "hello"; console.log(broken.toUpperCase());— read the compile error. Add atypeofguard around it and confirm the error goes away. - Stretch: load a JSON fixture (a file with
{ "name": "Login test", "passed": true, "duration": 1250 }), parse it, treat the result asunknown, narrow it with a type guard before passing it tosummarise. 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.