Practice: TypeScript Variables and Functions
August 1, 2025
In the previous article, we explored how TypeScript transforms the everyday building blocks of programming—variables and functions—from sources of potential runtime errors into reliable, self-documenting code. We covered type inference, explicit type annotations, function parameter types, and return type safety.
Now it's time to apply that knowledge with practical scenarios that mirror the challenges you'll face when building real applications. These exercises will help you develop the instincts for when to let TypeScript infer types versus when to be explicit, and how to write functions that truly communicate their intent.
Question 1: Variable Type Safety in Content Management (Easy)
You're building a blog content management system, and a junior developer has written some variable declarations that are causing type errors. The code mixes up different data types in ways that would cause runtime crashes.
let articleTitle: string = 42;
let publishedDate: Date = "2025-08-01";
let wordCount: number = "approximately 1500 words";
let categories: string[] = "technology, tutorial";
let authorInfo: { name: string; email: string } = "John Doe";
Your Task:
- Fix each variable assignment to match its declared type
- Then, for the following variables without type annotations, add explicit type annotations based on their intended use:
// Add type annotations to these variables
let siteTitle = "ExplainThis Blog";
let maxPostsPerPage = 10;
let isDarkMode = false;
let featuredTags = ["typescript", "javascript", "web-development"];
let lastBackup = new Date("2025-07-31");
<details> <summary>View Explanation</summary>
This exercise demonstrates one of the most common situations you'll encounter: data type mismatches that JavaScript would allow but TypeScript catches before they cause problems.
Let's fix the first set of variables:
// Fix: string should contain text, not a number
let articleTitle: string = "Understanding TypeScript Variables";
// Fix: Date should be a Date object, not a string
let publishedDate: Date = new Date("2025-08-01");
// Fix: number should contain numeric value, not descriptive text
let wordCount: number = 1500;
// Fix: string array needs actual array with strings
let categories: string[] = ["technology", "tutorial"];
// Fix: object should contain the required properties
let authorInfo: { name: string; email: string } = {
name: "John Doe",
email: "[email protected]",
};
Notice what TypeScript is really doing here. When you declare let articleTitle: string
, you're making a contract that says "this variable will always hold a string." TypeScript becomes your accountability partner, preventing you from breaking that promise later.
The Date
type is particularly interesting because it shows how TypeScript handles built-in JavaScript objects. You can't just assign a string to a Date
variable—you need to actually create a Date object using new Date()
.
For the second part, we need to add explicit type annotations:
let siteTitle: string = "ExplainThis Blog";
let maxPostsPerPage: number = 10;
let isDarkMode: boolean = false;
let featuredTags: string[] = ["typescript", "javascript", "web-development"];
let lastBackup: Date = new Date("2025-07-31");
You might wonder: "Couldn't TypeScript infer all of these types?" Absolutely! But this exercise helps you understand the explicit syntax. In real code, you'd often let TypeScript infer simple cases like these. The key is knowing when explicit annotations add value—like when you're working with data from external sources or when you want to enforce specific constraints.
</details>
Question 2: Function Parameters and Return Types (Easy-Medium)
You're building a user engagement analytics system. The following function calculates engagement scores, but it lacks type safety and has some logical issues that TypeScript could help catch.
function calculateEngagementScore(views, likes, comments, shares) {
const totalInteractions = likes + comments + shares;
const engagementRate = totalInteractions / views;
if (views === 0) {
return "No views yet";
}
if (engagementRate > 0.1) {
return "High engagement";
} else if (engagementRate > 0.05) {
return "Medium engagement";
} else {
return "Low engagement";
}
}
// These calls should work
console.log(calculateEngagementScore(1000, 50, 20, 10));
console.log(calculateEngagementScore(0, 0, 0, 0));
// These calls have problems that should be caught
console.log(calculateEngagementScore("1000", 50, 20, 10)); // String instead of number
console.log(calculateEngagementScore(500, 25)); // Missing arguments
Your Task:
- Add proper type annotations for all function parameters
- Add a return type annotation that accurately reflects what the function returns
- Fix the logic issue where division by zero isn't properly handled before the calculation
<details> <summary>View Explanation</summary>
This problem illustrates a common real-world scenario: functions that work most of the time but have edge cases and inconsistent return types that can cause problems downstream.
Let's start by examining what this function actually does. It takes numeric parameters and sometimes returns a string (like "High engagement") and sometimes returns a string (like "No views yet"). This inconsistency makes it hard for calling code to know what to expect.
First, let's add the parameter types:
function calculateEngagementScore(
views: number,
likes: number,
comments: number,
shares: number
) {
// function body
}
Now we need to think about the return type. The function returns different strings in different scenarios, so the return type should be string
. But there's a logic problem—we're doing division before checking if views is zero. Let's fix both issues:
function calculateEngagementScore(
views: number,
likes: number,
comments: number,
shares: number
): string {
// Check for zero views FIRST, before doing any calculations
if (views === 0) {
return "No views yet";
}
const totalInteractions = likes + comments + shares;
const engagementRate = totalInteractions / views;
if (engagementRate > 0.1) {
return "High engagement";
} else if (engagementRate > 0.05) {
return "Medium engagement";
} else {
return "Low engagement";
}
}
Now TypeScript will catch the problematic calls:
// This will cause a type error
calculateEngagementScore("1000", 50, 20, 10);
// Error: Argument of type 'string' is not assignable to parameter of type 'number'
// This will cause a type error
calculateEngagementScore(500, 25);
// Error: Expected 4 arguments, but got 2
Here's what's really powerful about this: TypeScript isn't just checking types—it's enforcing the function's contract. When you declare that the function takes four numbers, you're making a promise about how it should be called. TypeScript holds both the function implementation and the calling code accountable to that promise.
Notice how we also fixed the logical bug by moving the zero-check before the calculation. This is a great example of how thinking about types often leads to thinking more carefully about edge cases and error conditions.
The explicit return type annotation (): string
) serves as documentation and as a safety net. If you accidentally changed the function to return a number in some cases, TypeScript would immediately flag that as inconsistent with your declared return type.
</details>
Question 3: Optional Parameters and Nullable Data (Medium)
You're building an article recommendation system that needs to handle incomplete user data. The system should work even when users haven't provided all their preferences, but the current implementation crashes when data is missing.
function generateRecommendations(userId, preferences, maxResults) {
const user = getUserById(userId);
// This crashes if user is null
const userName = user.name;
// This crashes if preferences.categories is undefined
const categories = preferences.categories.join(", ");
// This uses wrong type for maxResults when not provided
const limit = maxResults || "default";
return {
userName: userName,
recommendedCategories: categories,
maxResults: limit,
};
}
// Mock function for the example
function getUserById(id) {
if (id === "user_123") {
return { name: "Alice", email: "[email protected]" };
}
return null; // User not found
}
// Test cases that should work:
console.log(
generateRecommendations("user_123", { categories: ["tech", "design"] }, 5)
);
// Test cases that currently crash:
console.log(generateRecommendations("user_999", { categories: ["tech"] }, 5)); // null user
console.log(generateRecommendations("user_123", {}, 5)); // missing categories
console.log(generateRecommendations("user_123", { categories: ["tech"] })); // missing maxResults
Your Task:
- Define proper types for the function parameters, making
maxResults
optional with a default value - Define a type for the
preferences
parameter wherecategories
is optional - Handle the case where
getUserById
returns null - Handle the case where
preferences.categories
is undefined - Ensure
maxResults
defaults to a number, not a string
<details> <summary>View Explanation</summary>
This exercise tackles one of the most challenging aspects of real-world programming: dealing with data that might not exist. APIs return null, users don't fill out all form fields, and configuration might be incomplete. TypeScript helps you handle these scenarios systematically rather than hoping for the best.
Let's start by defining the types we need. First, we need to understand what getUserById
returns:
// Define the user type (what getUserById returns)
type User = {
name: string;
email: string;
} | null; // Could be a user object OR null
// Define the preferences type with optional categories
type UserPreferences = {
categories?: string[]; // Optional array of strings
};
Now let's type the function with an optional parameter:
function generateRecommendations(
userId: string,
preferences: UserPreferences,
maxResults: number = 10 // Optional parameter with default value
): {
userName: string | null;
recommendedCategories: string;
maxResults: number;
} {
// Function implementation
}
Notice several important things here:
maxResults: number = 10
makes this parameter optional and provides a default value- The return type explicitly states what we'll return, including that
userName
might be null - We're being explicit about the structure of the returned object
Now for the implementation that safely handles missing data:
function generateRecommendations(
userId: string,
preferences: UserPreferences,
maxResults: number = 10
): {
userName: string | null;
recommendedCategories: string;
maxResults: number;
} {
const user = getUserById(userId);
// Handle null user safely
const userName = user ? user.name : null;
// Handle missing categories safely
const categories = preferences.categories
? preferences.categories.join(", ")
: "No preferences set";
return {
userName: userName,
recommendedCategories: categories,
maxResults: maxResults, // This is guaranteed to be a number
};
}
Let's break down the defensive programming patterns:
Null checking with the ternary operator: user ? user.name : null
is a concise way to say "if user exists, give me the name, otherwise give me null." TypeScript understands this pattern and knows that inside the user ?
part, user
is definitely not null.
Optional property checking: preferences.categories ? ... : ...
checks if the optional property exists before trying to use it. This prevents the "Cannot read property 'join' of undefined" error.
Default parameters: By using maxResults: number = 10
, we ensure that maxResults
is always a number, never undefined, and never a string like "default".
Here's the complete, type-safe solution:
type User = {
name: string;
email: string;
} | null;
type UserPreferences = {
categories?: string[];
};
function getUserById(id: string): User {
if (id === "user_123") {
return { name: "Alice", email: "[email protected]" };
}
return null;
}
function generateRecommendations(
userId: string,
preferences: UserPreferences,
maxResults: number = 10
): {
userName: string | null;
recommendedCategories: string;
maxResults: number;
} {
const user = getUserById(userId);
const userName = user ? user.name : null;
const categories = preferences.categories
? preferences.categories.join(", ")
: "No preferences set";
return {
userName: userName,
recommendedCategories: categories,
maxResults: maxResults,
};
}
// Now all these calls work safely:
console.log(
generateRecommendations("user_123", { categories: ["tech", "design"] }, 5)
);
console.log(generateRecommendations("user_999", { categories: ["tech"] }, 5)); // Handles null user
console.log(generateRecommendations("user_123", {}, 5)); // Handles missing categories
console.log(generateRecommendations("user_123", { categories: ["tech"] })); // Uses default maxResults
This approach transforms a fragile function that crashes unpredictably into a robust function that handles missing data gracefully. The types serve as a contract that documents exactly what might be missing and forces you to handle those cases explicitly.
The key insight is that TypeScript isn't just catching bugs—it's training you to think about all the ways your code might fail and to handle those failures proactively rather than reactively.
</details>