Conditionals
This article introduces 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
- Reduce if statements with associative arrays, callbacks, and polymorphism
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
Extracting 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