Lesson 3.2: Type Annotations vs Inference
Duration: 45 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Understand how TypeScript infers types automatically
- Know when explicit type annotations are necessary
- Apply the right approach in different situations
- Write cleaner code by leveraging inference where appropriate
What is Type Inference?
Type inference is TypeScript's ability to automatically determine the type of a variable based on its value. You do not always need to write types explicitly.
// Explicit annotation - you tell TypeScript the type
let message: string = "Hello";
// Type inference - TypeScript figures it out
let message = "Hello"; // TypeScript knows this is a string
Both lines create a variable of type string. The second line is shorter and equally type-safe.
How Inference Works
TypeScript looks at the value you assign and determines the type:
let name = 'Alice'; // Inferred as string
let age = 25; // Inferred as number
let isActive = true; // Inferred as boolean
let scores = [90, 85]; // Inferred as number[]
You can verify the inferred type by hovering over the variable in VS Code, or by trying to assign a wrong type:
let count = 10;
count = 'ten'; // Error! Type 'string' is not assignable to type 'number'
Even without an explicit annotation, TypeScript protects you from type errors.
When Inference Works Best
Variable Initialization
When you declare and initialize a variable in one line, inference works perfectly:
// Good - inference handles these
let username = 'bob_dev';
let loginCount = 42;
let premiumUser = false;
let tags = ['typescript', 'javascript'];
Return Types from Expressions
TypeScript infers types from expressions:
let x = 10;
let y = 20;
let sum = x + y; // Inferred as number
let combined = x + '!'; // Inferred as string (number + string = string)
Array Methods
TypeScript tracks types through array methods:
let numbers = [1, 2, 3, 4, 5];
let doubled = numbers.map((n) => n * 2); // Inferred as number[]
let strings = numbers.map((n) => String(n)); // Inferred as string[]
let evens = numbers.filter((n) => n % 2 === 0); // Inferred as number[]
When to Use Explicit Annotations
1. Empty Arrays
TypeScript cannot infer the type of an empty array:
// Bad - TypeScript infers any[]
let items = [];
// Good - explicit type
let items: string[] = [];
items.push("apple");
items.push("banana");
2. Variables Without Initial Values
When declaring without assigning:
// Bad - TypeScript doesn't know the type
let result;
result = calculateTotal(); // What type is this?
// Good - declare the expected type
let result: number;
result = calculateTotal();
3. Function Parameters
TypeScript cannot infer parameter types from usage:
// Bad - parameters are implicitly 'any'
function add(a, b) {
return a + b;
}
// Good - explicit parameter types
function add(a: number, b: number) {
return a + b;
}
4. When Inference Would Be Too Broad
Sometimes TypeScript infers a type that is broader than you want:
// Inferred as string - but we want a specific set of values
let status = "pending";
status = "invalid status"; // No error! Any string is allowed
// Better - use a literal type (covered in next lesson)
let status: "pending" | "approved" | "rejected" = "pending";
5. Complex Object Literals
For objects with many properties, annotations help document intent:
// Without annotation - works but unclear intent
let config = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3
};
// With annotation - documents the expected shape
let config: {
apiUrl: string;
timeout: number;
retries: number;
} = {
apiUrl: "https://api.example.com",
timeout: 5000,
retries: 3
};
Best Practices
Let TypeScript Infer When Obvious
Do not add annotations that TypeScript can clearly infer:
// Unnecessary - TypeScript knows it's a string
let name: string = "Alice";
// Better - cleaner code
let name = "Alice";
Add Annotations for Clarity
Add them when they help readers understand the code:
// What does this return? Have to read the function body
function processData(input) {
// ...complex logic...
}
// Clear at a glance
function processData(input: RawData): ProcessedResult {
// ...complex logic...
}
Always Annotate Function Parameters
This is the most important place for annotations:
// Always annotate parameters
function greet(name: string, times: number): void {
for (let i = 0; i < times; i++) {
console.log(`Hello, ${name}!`);
}
}
Consider Annotating Public APIs
When others will use your code, explicit types serve as documentation:
// For internal use - inference is fine
const internalHelper = (x: number) => x * 2;
// For public API - explicit return type documents the contract
export function calculateTax(amount: number, rate: number): number {
return amount * rate;
}
The const and let Difference
TypeScript infers types differently for const and let:
With let - Infers the General Type
let status = 'active'; // Type: string (any string allowed)
status = 'inactive'; // OK
status = 'anything'; // OK
With const - Infers the Literal Type
const status = 'active'; // Type: "active" (only this exact value)
// status = "inactive"; // Error! Cannot reassign const
This is called "literal narrowing" and is useful for creating precise types:
const API_VERSION = 'v2'; // Type: "v2" (exact value)
const MAX_RETRIES = 3; // Type: 3 (exact value)
const FEATURE_FLAGS = ['dark_mode', 'beta']; // Type: string[] (not narrowed for arrays)
Practical Examples
Example 1: Configuration Object
// Let inference work for simple values
const appName = 'MyApp';
const version = '1.0.0';
const debug = false;
// Annotate the combined config for clarity
interface AppConfig {
name: string;
version: string;
debug: boolean;
features: string[];
}
const config: AppConfig = {
name: appName,
version: version,
debug: debug,
features: [], // Empty array needs the interface to know the type
};
Example 2: Data Processing
// Input data - annotate because it comes from external source
const userData: { name: string; scores: number[] } = {
name: 'Alice',
scores: [85, 92, 78],
};
// Processing - let inference work
const total = userData.scores.reduce((sum, score) => sum + score, 0);
const average = total / userData.scores.length;
const passed = average >= 70;
console.log(`${userData.name}: ${average.toFixed(1)} - ${passed ? 'Passed' : 'Failed'}`);
Example 3: API Response Handling
// Annotate what we expect from the API
interface ApiResponse {
success: boolean;
data: string[];
error?: string;
}
// Simulate API call - annotate return type
function fetchData(): ApiResponse {
return {
success: true,
data: ['item1', 'item2', 'item3'],
};
}
// Let inference work with the result
const response = fetchData();
const items = response.data; // Inferred as string[]
const count = items.length; // Inferred as number
const firstItem = items[0]; // Inferred as string
Exercises
Exercise 1: Identify Inferred Types
What type does TypeScript infer for each variable?
let a = 'hello';
let b = 42;
let c = true;
let d = [1, 2, 3];
let e = { x: 10, y: 20 };
let f = null;
let g = undefined;
Solution
let a = 'hello'; // string
let b = 42; // number
let c = true; // boolean
let d = [1, 2, 3]; // number[]
let e = { x: 10, y: 20 }; // { x: number; y: number }
let f = null; // null (not very useful!)
let g = undefined; // undefined (not very useful!)
Note: f and g are edge cases where inference produces narrow types that are not helpful. You would typically use them with union types: let f: string | null = null;
Exercise 2: Add Necessary Annotations
This code needs type annotations in some places. Add them where necessary:
let items = [];
function multiply(a, b) {
return a * b;
}
let username;
username = 'Alice';
const scores = [90, 85, 92];
const average = scores.reduce((a, b) => a + b) / scores.length;
Solution
// Empty array needs annotation
let items: string[] = [];
// Function parameters always need annotations
function multiply(a: number, b: number) {
return a * b;
}
// Variable without initial value needs annotation
let username: string;
username = 'Alice';
// These are fine - inference works
const scores = [90, 85, 92];
const average = scores.reduce((a, b) => a + b) / scores.length;
Exercise 3: Simplify Redundant Annotations
Remove unnecessary type annotations while keeping type safety:
let greeting: string = 'Hello, World!';
let count: number = 0;
let isValid: boolean = true;
let numbers: number[] = [1, 2, 3, 4, 5];
let doubled: number[] = numbers.map((n: number): number => n * 2);
Solution
// All these can rely on inference
let greeting = 'Hello, World!';
let count = 0;
let isValid = true;
let numbers = [1, 2, 3, 4, 5];
let doubled = numbers.map((n) => n * 2);
Exercise 4: Fix the Type Errors
This code has issues. Add annotations or fix values to make it type-safe:
let result;
result = 'success';
result = 42;
function getLength(text) {
return text.length;
}
let data = [];
data.push({ name: 'Alice' });
data.push('invalid');
Solution
// If result should be string OR number:
let result: string | number;
result = "success";
result = 42;
// If result should only be one type:
let result: string;
result = "success";
// result = 42; // Remove this line
// Always annotate parameters
function getLength(text: string): number {
return text.length;
}
// Annotate the array type
let data: { name: string }[] = [];
data.push({ name: "Alice" });
// data.push("invalid"); // This would now error
Key Takeaways
- TypeScript infers types from initial values automatically
- Let inference work when it is obvious - do not over-annotate
- Always annotate function parameters - TypeScript cannot infer them
- Annotate empty arrays - TypeScript defaults to
any[]otherwise - Annotate variables without initial values - TypeScript needs to know the type
- const narrows to literal types -
const x = "a"has type"a", notstring - Use annotations for documentation - especially for public APIs
Resources
| Resource | Type | Description |
|---|---|---|
| TypeScript Handbook: Type Inference | Documentation | Official guide to inference |
| TypeScript Playground | Tool | Hover over variables to see inferred types |
| TypeScript Deep Dive: Type Inference | Tutorial | In-depth inference explanation |
Next Lesson
Now that you understand when to use annotations and when to let TypeScript infer, let us explore union types and literal types - powerful features for creating precise type definitions.