From Zero to AI

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

  1. TypeScript infers types from initial values automatically
  2. Let inference work when it is obvious - do not over-annotate
  3. Always annotate function parameters - TypeScript cannot infer them
  4. Annotate empty arrays - TypeScript defaults to any[] otherwise
  5. Annotate variables without initial values - TypeScript needs to know the type
  6. const narrows to literal types - const x = "a" has type "a", not string
  7. 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.

Continue to Lesson 3.3: Union and Literal Types