Control Flow in TypeScript
In this lesson, we'll explore how TypeScript handles program flow control through conditionals and loops. These are essential tools for writing programs that can make decisions and repeat actions.
What You'll Learn
By the end of this lesson you will write a function that takes a value which could be one of several types and does the right thing with each — and, more importantly, you will be able to read the compiler error you get when you reach for a property before proving the value has it. That error is one of the most common in TypeScript and it has a fixed shape you can learn to decode.
This is where TypeScript stops being a labelling exercise and starts being useful. The compiler follows your if statements: check something about a value, and inside that branch it knows more about the value than it did outside. That single behaviour — narrowing — is the foundation of nearly every practical pattern later in the course. You arrive here able to write typed functions and read a union like string | number (Functions, Variables and Types); what is new is that the compiler now tracks which member of that union you are holding at each point in the code.
If Statements
TypeScript uses the same conditional syntax as JavaScript, with added type safety:
const age: number = 18;
if (age >= 18) {
console.log("You are an adult");
} else {
console.log("You are a minor");
}
If-Else If-Else
Handle multiple conditions with else if:
const score: number = 85;
if (score >= 90) {
console.log("Grade: A");
} else if (score >= 80) {
console.log("Grade: B");
} else if (score >= 70) {
console.log("Grade: C");
} else if (score >= 60) {
console.log("Grade: D");
} else {
console.log("Grade: F");
}
Ternary Operator
For simple conditions, use the ternary operator:
const temperature: number = 25;
const weather: string = temperature > 20 ? "warm" : "cold";
console.log(`It's ${weather} today`);
// Nested ternary (use sparingly for readability)
const category: string =
temperature > 30 ? "hot" :
temperature > 20 ? "warm" :
temperature > 10 ? "cool" : "cold";
console.log(`Temperature category: ${category}`);
Switch Statements
Switch statements provide a clean way to handle multiple cases:
const day: number = 3;
let dayName: string;
switch (day) {
case 1:
dayName = "Monday";
break;
case 2:
dayName = "Tuesday";
break;
case 3:
dayName = "Wednesday";
break;
case 4:
dayName = "Thursday";
break;
case 5:
dayName = "Friday";
break;
case 6:
case 7:
dayName = "Weekend";
break;
default:
dayName = "Invalid day";
}
console.log(`Day ${day} is ${dayName}`);
For Loops
Traditional For Loop
console.log("Counting to 5:");
for (let i = 1; i <= 5; i++) {
console.log(i);
}
Debug
This loop should add up 1 + 2 + 3 + 4 + 5 and log 'Sum: 15'. Instead it crashes on the very first iteration before printing anything. Predict what goes wrong, then fix it so it logs 'Sum: 15'. (The runner strips types and just runs the code, so this is a real runtime crash, not a compiler complaint.)
let sum = 0;
for (const i = 1; i <= 5; i++) {
sum += i;
}
console.log("Sum:", sum);Expected output:
Sum: 15
For-Of Loop
Iterate over array values:
const fruits: string[] = ["apple", "banana", "orange"];
console.log("Fruits:");
for (const fruit of fruits) {
console.log(fruit);
}
For-In Loop
Iterate over object keys (use with caution on arrays). The key as keyof typeof person cast below is a preview — it takes the keys of a value's type so person[key] type-checks; you'll learn keyof properly in the Generics lesson. Read it for now; you'll build it later:
const person = {
name: "Alice",
age: 30,
city: "New York"
};
console.log("Person properties:");
for (const key in person) {
console.log(`${key}: ${person[key as keyof typeof person]}`);
}
While Loops
Execute code while a condition is true:
let countdown: number = 5;
console.log("Countdown:");
while (countdown > 0) {
console.log(countdown);
countdown--;
}
console.log("Blastoff!");
Do-While Loops
Execute code at least once, then check the condition:
let attempts: number = 0;
const maxAttempts: number = 3;
do {
attempts++;
console.log(`Attempt ${attempts}`);
} while (attempts < maxAttempts);
console.log("Done!");
Break and Continue
Break
Exit a loop early:
console.log("Finding first number divisible by 7:");
for (let i = 1; i <= 20; i++) {
if (i % 7 === 0) {
console.log(`Found: ${i}`);
break;
}
}
Continue
Skip to the next iteration. The break fence above was written out in full; this one leaves the guard to you, and runs (printing the wrong thing) until you supply it:
console.log("Odd numbers from 1 to 10:");
for (let i = 1; i <= 10; i++) {
// TODO: the break fence above exits the loop entirely when its condition
// fires. continue does the smaller thing — it abandons THIS iteration and
// moves to the next. Write the guard that skips even numbers here. Until you
// do, the fence prints all ten; when it is right, only the odds appear.
console.log(i);
}
Type Guards
TypeScript can narrow types within conditional blocks:
function processValue(value: string | number): void {
if (typeof value === "string") {
// TypeScript knows value is a string here
console.log(`String length: ${value.length}`);
} else {
// TypeScript knows value is a number here
console.log(`Number doubled: ${value * 2}`);
}
}
processValue("Hello");
processValue(42);
The shape of that function is the point, not just its content. The guard comes first and the work happens inside the branch — never the other way round. The reason is that a union type is a promise about what the value might be, so anything true of only one member is off-limits until you have ruled the others out. Reach for .length before the check and the compiler stops you. This fence is marked no-run because its whole point is to fail to compile:
function processValue(value: string | number): void {
console.log(value.length);
}
processValue("Hello");
Checked with tsc --strict, that is rejected with:
error TS2339: Property 'length' does not exist on type 'string | number'.
Property 'length' does not exist on type 'number'.
Read that message closely, because you will see this family of error constantly and it is telling you something precise. It does not say length does not exist — it says it does not exist on the union. A string has a length; a number does not; and while the value could still be either, the compiler will only let you use what is true of both. The error is not an obstacle to work around, it is the compiler pointing at a missing check.
Here is the runtime shadow — the same access, forced past the compiler, so you can see what the running program does with it:
function processValue(value: string | number): void {
console.log(`length is ${(value as string).length}`);
}
processValue("Hello");
processValue(42 as unknown as string);
It prints length is 5 and then length is undefined. No crash, no error — just undefined quietly entering your program, where it will typically cause a failure much later and somewhere else. That is why the compile-time error is worth having: at run time this mistake does not announce itself.
Predict
Inside the else branch, the typeof value === 'string' check has already failed. Given the parameter type string | number, what type has the compiler narrowed value to on the value * 2 line?
function processValue(value: string | number): void {
if (typeof value === "string") {
console.log(value.length);
} else {
console.log(value * 2);
}
}Recall
Without scrolling up: the type guard above hinges on the typeof operator you met in Variables and Types. What kind of value does typeof value evaluate to at runtime, and what would typeof value === 'string' therefore be checking?
Arrange the code
These four lines take a value that could be a string or a number, test which one it is, use that answer to measure it safely, and report the result. The pieces are shuffled. Put them back in an order that runs — then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?
const input: string | number = "hello";const size = isText ? input.length : 0;console.log(`size: ${size}`);const isText = typeof input === "string";
Transfer
JavaScript programmers guard with truthiness constantly: if (value) { ... } to mean 'if there is something here'. TypeScript understands that check and narrows on it. But for a value typed string | number the two ideas come apart, and one specific input is where they part company. Which statement identifies what the truthiness check gets wrong that an explicit typeof check does not?
Try It Yourself
Practice control flow in the playground below:
// If-else example
const temperature: number = 22;
if (temperature > 30) {
console.log("It's hot outside!");
} else if (temperature > 20) {
console.log("It's a pleasant day!");
} else {
console.log("It's cold outside!");
}
// For loop example
console.log("\nMultiplication table for 5:");
for (let i = 1; i <= 10; i++) {
console.log(`5 x ${i} = ${5 * i}`);
}
// For-of loop example
const colors: string[] = ["red", "green", "blue"];
console.log("\nColors:");
for (const color of colors) {
console.log(color);
}
// While loop example
let number: number = 1;
console.log("\nPowers of 2 (up to 64):");
while (number <= 64) {
console.log(number);
number *= 2;
}
// Switch statement example
const month: number = 3;
let season: string;
switch (month) {
case 12:
case 1:
case 2:
season = "Winter";
break;
case 3:
case 4:
case 5:
season = "Spring";
break;
case 6:
case 7:
case 8:
season = "Summer";
break;
case 9:
case 10:
case 11:
season = "Fall";
break;
default:
season = "Unknown";
}
console.log(`\nMonth ${month} is in ${season}`);
// Break and continue example
console.log("\nNumbers from 1 to 10, skipping 5:");
for (let i = 1; i <= 10; i++) {
if (i === 5) {
continue;
}
console.log(i);
}
Try these exercises:
- Write a program that prints the first 10 Fibonacci numbers
- Create a function that uses a switch statement to convert day numbers to day names
- Use a for loop to calculate the factorial of a number
- Write code that uses break to find the first number greater than 100 that's divisible by both 7 and 11
- Create a type guard function that handles string, number, and boolean types differently
Reading about conditions and loops is not the same as building with them. This is a build task: a small program that reports its own pass/fail. You are given three empty, fully typed functions to finish, each one a small decision or counting problem. Run it and it fails immediately, telling you which function is still missing. Implement each one until every check passes and it prints All checks passed.
The three functions reuse exactly what this lesson taught: an if/else if chain that returns the first matching result, the modulo operator to test divisibility, and a counting for loop with a running total. The starter already has the data, the stubs, and the checks — you write only the body of each function. Nothing above spells out all three answers, so you will have to assemble them yourself.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// The data: fixed inputs to check against. Do NOT change these values.
const limit: number = 15;
const childAge: number = 8;
const adultAge: number = 40;
const seniorAge: number = 70;
// TODO 1: return a word for one FizzBuzz number.
// Divisible by 3 AND 5 -> "FizzBuzz"; by 3 only -> "Fizz";
// by 5 only -> "Buzz"; otherwise the number as a string.
// fizzbuzzWord(15) -> "FizzBuzz", fizzbuzzWord(9) -> "Fizz", fizzbuzzWord(7) -> "7"
function fizzbuzzWord(n: number): string | undefined {
return undefined; // replace undefined with your code
}
// TODO 2: count how many integers from 1 to limit are divisible by factor.
// countDivisibleBy(15, 3) -> 5
function countDivisibleBy(limit: number, factor: number): number | undefined {
return undefined; // replace undefined with your code
}
// TODO 3: return a ticket fare for an age.
// Under 13 -> 5; 65 or older -> 8; everyone else -> 12.
// fareFor(8) -> 5, fareFor(40) -> 12, fareFor(70) -> 8
function fareFor(age: number): number | undefined {
return undefined; // replace undefined with your code
}
// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(
fizzbuzzWord(15),
"FizzBuzz",
"fizzbuzzWord(n) should return 'FizzBuzz' when n is divisible by both 3 and 5",
);
assert.strictEqual(
fizzbuzzWord(9),
"Fizz",
"fizzbuzzWord(n) should return 'Fizz' when n is divisible by 3 only",
);
assert.strictEqual(
fizzbuzzWord(10),
"Buzz",
"fizzbuzzWord(n) should return 'Buzz' when n is divisible by 5 only",
);
assert.strictEqual(
fizzbuzzWord(7),
"7",
"fizzbuzzWord(n) should return the number as a string when it is divisible by neither 3 nor 5",
);
assert.strictEqual(
countDivisibleBy(limit, 3),
5,
"countDivisibleBy(limit, factor) should count how many integers from 1 to limit divide evenly by factor",
);
assert.strictEqual(
fareFor(childAge),
5,
"fareFor(age) should charge 5 for anyone under 13",
);
assert.strictEqual(
fareFor(adultAge),
12,
"fareFor(age) should charge 12 for a standard adult",
);
assert.strictEqual(
fareFor(seniorAge),
8,
"fareFor(age) should charge 8 for anyone 65 or older",
);
console.log("All checks passed.");
console.log("fizzbuzzWord(15):", fizzbuzzWord(15));
console.log("countDivisibleBy(15, 3):", countDivisibleBy(limit, 3));
console.log("fareFor(70):", fareFor(seniorAge));Expected output:
All checks passed. fizzbuzzWord(15): FizzBuzz countDivisibleBy(15, 3): 5 fareFor(70): 8
Once it passes, try two variations and predict each before running:
- Reorder the FizzBuzz chain. In
fizzbuzzWord, move the divisible-by-3 test (n % 3 === 0 → "Fizz") above the both-divisible test. Predict whatfizzbuzzWord(15)returns before running. 15 is divisible by 3 and by 5, but the chain returns on the first match — so it now answers"Fizz"and never reaches"FizzBuzz", and the first check fails. This is the order-matters point in your own hands. - Off-by-one in the count loop. In
countDivisibleBy, change the loop bound fromi <= limittoi < limit. Decide whatcountDivisibleBy(15, 3)returns before running. Stopping at 14 drops the multiple15itself, so the count comes back4instead of5and the check fails — the classic inclusive-vs-exclusive loop boundary.
Key Takeaways
- Use if-else for conditional logic
- Switch statements are great for multiple discrete values
- For loops iterate a specific number of times
- For-of loops iterate over array values
- While loops continue until a condition is false
- Break exits a loop, continue skips to the next iteration
- TypeScript's type system works with control flow for type narrowing
Pro Tip: Choose the right control structure for the job. For-of is great for arrays, while is better when you don't know how many iterations you need, and if-else handles complex conditions!
Next Steps
You now know how to direct program flow with conditionals and loops. Next, you'll learn how to organize the data those loops operate on — arrays, objects, tuples, maps, and sets.
Ready to continue? Head to Data Structures!
Next lesson
Data Structures
Learn typed arrays, tuples, and objects in TypeScript. Build type-safe data structures with interfaces, readonly, and generics.
25 min