Conditionals
Let's learn the best practices for writing conditionals. Leveraging the TypeScript type system lets you write safer, more readable branches.
Key points
- Make conditions easy to understand
- Reduce the reader's burden
- Use early returns
- Don't nest deeply
- Consider techniques other than if statements
Don't write the same branch in many places
When the same conditional branch is scattered across multiple places, the scope of impact for a spec change grows. Keep branches in one place.
Bad
// ❌ The same branch is scattered
type SkillName = 'Tackle' | 'Quick Attack' | 'Iron Tail' | 'Thunderbolt' | 'Thunder';
function getAttackPoint(skill: SkillName): number {
switch (skill) {
case 'Iron Tail':
return 30;
case 'Thunderbolt':
return 100;
case 'Thunder':
return 1000;
default:
return 0;
}
}
function getHp(skill: SkillName, currentHp: number): number {
let attackPoint: number;
if (skill === 'Tackle') {
attackPoint = 10;
} else if (skill === 'Quick Attack') {
attackPoint = 20;
} else {
attackPoint = getAttackPoint(skill);
}
return currentHp - attackPoint;
}
Good
// ✅ Keep the branch in one place
type SkillName = 'Tackle' | 'Quick Attack' | 'Iron Tail' | 'Thunderbolt' | 'Thunder';
const SKILL_ATTACK_POINTS: Record<SkillName, number> = {
'Tackle': 10,
'Quick Attack': 20,
'Iron Tail': 30,
'Thunderbolt': 100,
'Thunder': 1000,
} as const;
function getAttackPoint(skill: SkillName): number {
return SKILL_ATTACK_POINTS[skill];
}
function getHp(skill: SkillName, currentHp: number): number {
const attackPoint = getAttackPoint(skill);
return currentHp - attackPoint;
}
Compare with strict equality
In TypeScript, too, use === (the strict equality operator). == (the equality operator) performs implicit type conversion, which can cause unexpected bugs.
Bad
// ❌ The equality operator triggers type conversion (an example you can still write in TypeScript)
const input: any = '12';
if (input == 12) { } // true (the string is converted to a number)
if (null == undefined) { } // true (null and undefined are equal)
Note that == comparisons between literals of non-overlapping types, such as '12' == 10 + 2 or 0 == false, are caught by TypeScript itself as a TS2367 compile error. However, comparisons via any like the above, or null == undefined, pass compilation, so standardize on ===.
Good
// ✅ Use the strict equality operator
if ('12' === String(10 + 2)) { } // Align the types explicitly
if (0 === 0) { } // Compare with the same type
if (value === null || value === undefined) { } // Check explicitly
// ✅ Use ?? or ?. for nullish checks
const result = value ?? 'default';
const name = user?.profile?.name;
Evaluate the most common condition first
Write if conditions starting from the case that occurs most frequently.
Bad
// ❌ Evaluating the rare case first
type UserType = 'free' | 'premium' | 'admin';
function processUser(user: User): void {
if (user.type === 'admin') {
// Process for admin accounts (the fewest)
} else if (user.type === 'premium') {
// Process for premium accounts
} else {
// Process for regular accounts (the most)
}
}
Good
// ✅ Evaluating the frequent case first
function processUser(user: User): void {
if (user.type === 'free') {
// Process for regular accounts (the most)
} else if (user.type === 'premium') {
// Process for premium accounts
} else {
// Process for admin accounts (the fewest)
}
}
Encapsulate boundary conditions
Replacing the values used in boundary conditions with well-named variables improves readability.
Bad
// ❌ Magic numbers are scattered
if (age >= 18 && age < 65) {
console.log('Working-age population');
}
if (items.length > 100) {
console.log('Pagination is needed');
}
Good
// ✅ Turn boundary conditions into variables
const WORKING_AGE_MIN = 18;
const WORKING_AGE_MAX = 65;
const MAX_ITEMS_PER_PAGE = 100;
const isWorkingAge = age >= WORKING_AGE_MIN && age < WORKING_AGE_MAX;
if (isWorkingAge) {
console.log('Working-age population');
}
const needsPagination = items.length > MAX_ITEMS_PER_PAGE;
if (needsPagination) {
console.log('Pagination is needed');
}
Extract complex conditions into functions
Grouping complex conditions into a function improves readability and enables reuse.
Bad
// ❌ The condition is complex and hard to read
if (user.age >= 18 && user.isEmailVerified && !user.isBanned && user.subscription.status === 'active') {
// Process
}
// ❌ A regular expression is written directly in the condition
const regex = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
if (regex.test(text)) {
console.log('Contains emoji');
}
Good
// ✅ Extract the condition as a function
function canAccessPremiumContent(user: User): boolean {
return (
user.age >= 18 &&
user.isEmailVerified &&
!user.isBanned &&
user.subscription.status === 'active'
);
}
if (canAccessPremiumContent(user)) {
// Process
}
// ✅ Turn the regular expression into a function too
function containsEmoji(text: string): boolean {
const emojiRegex = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
return emojiRegex.test(text);
}
if (containsEmoji(text)) {
console.log('Contains emoji');
}
Use an associative array (object) instead of a switch statement
Switch statements tend to become verbose. Consider replacing them with an associative array using TypeScript's Record type.
Bad
// ❌ The switch statement gets long
function getAreaName(areaCode: number): string {
switch (areaCode) {
case 1:
return 'Chiyoda';
case 2:
return 'Chuo';
case 3:
return 'Minato';
case 4:
return 'Shinjuku';
default:
return 'Other ward';
}
}
Good
// ✅ Manage it with an object
const AREA_NAMES: Record<number, string> = {
1: 'Chiyoda',
2: 'Chuo',
3: 'Minato',
4: 'Shinjuku',
};
function getAreaName(areaCode: number): string {
return AREA_NAMES[areaCode] ?? 'Other ward';
}
// ✅ When making it type-safe
type AreaCode = 1 | 2 | 3 | 4;
const AREA_NAMES_SAFE: Record<AreaCode, string> = {
1: 'Chiyoda',
2: 'Chuo',
3: 'Minato',
4: 'Shinjuku',
};
function getAreaNameSafe(areaCode: AreaCode): string {
return AREA_NAMES_SAFE[areaCode];
}
Use early returns
Using early returns (guard clauses) reduces nesting and improves readability.
Bad
// ❌ Deep nesting
async function getUserById(userId: number): Promise<User | null> {
if (typeof userId === 'number' && userId > 0) {
const response = await fetch('/users', {
method: 'POST',
body: JSON.stringify({ userId }),
});
if (response.ok) {
const result = await response.json();
return result;
}
return null;
}
return null;
}
Good
// ✅ Flat with early returns
async function getUserById(userId: number): Promise<User | null> {
if (typeof userId !== 'number' || userId <= 0) {
return null;
}
const response = await fetch('/users', {
method: 'POST',
body: JSON.stringify({ userId }),
});
if (!response.ok) {
return null;
}
return response.json();
}
Prevent nesting
Deeply nested code is hard to read and a source of bugs.
Bad
// ❌ Deep nesting
function isActiveUser(user: User | null): boolean {
if (user != null) {
if (user.startDate <= today && (user.endDate == null || today <= user.endDate)) {
if (user.stopped) {
return false;
} else {
return true;
}
} else {
return false;
}
} else {
return false;
}
}
Good
// ✅ Flat with early returns
function isActiveUser(user: User | null): boolean {
if (user === null) return false;
if (user.startDate > today) return false;
if (user.endDate !== null && today > user.endDate) return false;
if (user.stopped) return false;
return true;
}
// ✅ Turn the condition into a function as well
function isWithinActivePeriod(user: User, today: Date): boolean {
return user.startDate <= today && (user.endDate === null || today <= user.endDate);
}
function isActiveUser(user: User | null): boolean {
if (user === null) return false;
if (!isWithinActivePeriod(user, today)) return false;
if (user.stopped) return false;
return true;
}
Branching with TypeScript types
In TypeScript, you can branch safely using types.
Discriminated Union
// ✅ Discriminate with a type property
type Shape =
| { type: 'circle'; radius: number }
| { type: 'rectangle'; width: number; height: number }
| { type: 'triangle'; base: number; height: number };
function calculateArea(shape: Shape): number {
switch (shape.type) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
}
}
Exhaustiveness check
// ✅ The compiler confirms that every case is handled
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
type Status = 'pending' | 'approved' | 'rejected';
function getStatusMessage(status: Status): string {
switch (status) {
case 'pending':
return 'Awaiting approval';
case 'approved':
return 'Approved';
case 'rejected':
return 'Rejected';
default:
return assertNever(status); // A compile error occurs if a new Status is added
}
}
Reduce if statements with callbacks or classes
Reducing if statements lowers code complexity.
Bad
// ❌ Branching the process with an if statement
function executeQuery(type: 'username' | 'password', data: UserData): void {
const db = getDBConnection();
db.startTransaction();
if (type === 'username') {
updateUsername(data, db);
} else if (type === 'password') {
updatePassword(data, db);
}
db.endTransaction();
}
Good
// ✅ Inject the process with a callback function
type UpdateFunction = (data: UserData, db: Database) => void;
function executeQuery(updateFn: UpdateFunction, data: UserData): void {
const db = getDBConnection();
db.startTransaction();
updateFn(data, db);
db.endTransaction();
}
// Usage
executeQuery(updateUsername, { userId: 1, userName: 'Tom' });
executeQuery(updatePassword, { userId: 1, password: 'secret' });
Remove if statements with polymorphism
// ✅ Eliminate branches with interfaces and classes
interface Animal {
eat(): void;
sleep(): void;
}
class Dog implements Animal {
eat(): void {
console.log('Eat dog food');
}
sleep(): void {
console.log('Sleep in the kennel');
}
}
class Cat implements Animal {
eat(): void {
console.log('Eat cat food');
}
sleep(): void {
console.log('Sleep on the sofa');
}
}
function actAnimal(animal: Animal): void {
animal.eat();
animal.sleep();
}
// Usage (no if statements)
const dog = new Dog();
actAnimal(dog);
const cat = new Cat();
actAnimal(cat);
Exercises
Refactor the following code with early returns
// Problem
function isCorrectInput(member: Member): boolean {
if (member.id) {
if (member.name) {
if (member.age) {
if (member.email) {
return true;
} else {
console.error('Email address is not entered');
return false;
}
} else {
console.error('Age is not entered');
return false;
}
} else {
console.error('Name is not entered');
return false;
}
} else {
console.error('id is missing');
return false;
}
}
Answer:
interface ValidationResult {
isValid: boolean;
error?: string;
}
function validateMember(member: Member): ValidationResult {
if (!member.id) {
return { isValid: false, error: 'id is missing' };
}
if (!member.name) {
return { isValid: false, error: 'Name is not entered' };
}
if (!member.age) {
return { isValid: false, error: 'Age is not entered' };
}
if (!member.email) {
return { isValid: false, error: 'Email address is not entered' };
}
return { isValid: true };
}
function isCorrectInput(member: Member): boolean {
const result = validateMember(member);
if (!result.isValid && result.error) {
console.error(result.error);
}
return result.isValid;
}
Improvements:
- Eliminate nesting with early returns
- Separate the validation logic
- Structure the error messages
Summary of conditionals
- Keep branches in one place
- Use the strict equality operator (
===) - Evaluate the frequent case first
- Turn boundary conditions into variables
- Extract complex conditions into functions
- Consider an object instead of a switch statement
- Reduce nesting with early returns
- Stay type-safe with discriminated unions
- Reduce if statements with polymorphism
What to read next
- Class design — replace branches with polymorphism
- Function design — extract complex conditions into functions