Skip to main content

🚨 Lesson 7.2: Custom Errors and Exceptions

In the last lesson, you learned to catch errors. Now it's time to create them. When a user tries to withdraw more money than they have, or registers with a username that's already taken, a generic Error doesn't tell anyone what actually went wrong. Custom exceptions give your code a vocabulary for communicating specific problems — making bugs easier to find and error handling more precise.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Raise/throw built-in exceptions with meaningful messages
  • Create custom exception classes that extend built-in error types
  • Add extra data to your custom exceptions (error codes, context)
  • Build an exception hierarchy for a domain (e.g., banking errors)
  • Use guard clauses to validate inputs at function boundaries
  • Know when to throw vs. when to return error values

Estimated Time: 50 minutes

Project: Build a bank account system with a custom error hierarchy

📑 In This Lesson

Throwing/Raising Errors

You already know that runtime errors happen automatically — divide by zero, access a missing key, etc. But you can also create and throw errors yourself when your code detects a problem that the language wouldn't catch on its own.

# Python uses 'raise' to throw errors
def set_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative")
    if age > 150:
        raise ValueError("Age seems unrealistic")
    return age

# This works fine
print(set_age(25))    # 25

# This raises an error you created
try:
    set_age(-5)
except ValueError as e:
    print(f"Error: {e}")    # Error: Age cannot be negative

# You can raise any built-in exception type
raise TypeError("Expected a string, got int")
raise FileNotFoundError("Config file missing: app.yaml")
raise PermissionError("User lacks admin access")
raise RuntimeError("Something unexpected happened")
// JavaScript uses 'throw' to throw errors
function setAge(age) {
    if (age < 0) {
        throw new RangeError("Age cannot be negative");
    }
    if (age > 150) {
        throw new RangeError("Age seems unrealistic");
    }
    return age;
}

// This works fine
console.log(setAge(25));    // 25

// This throws an error you created
try {
    setAge(-5);
} catch (error) {
    console.log(`Error: ${error.message}`);  // Error: Age cannot be negative
}

// You can throw any built-in error type
throw new TypeError("Expected a string, got number");
throw new RangeError("Value out of acceptable range");
throw new ReferenceError("Configuration not initialized");
throw new Error("Something unexpected happened");

// JS can technically throw anything — but don't do this!
// throw "bad!";        // ❌ No stack trace, no .message
// throw 42;            // ❌ Loses all error information
// throw { msg: "!" };  // ❌ Not an Error instance
// C# uses 'throw' to throw errors
static int SetAge(int age)
{
    if (age < 0)
        throw new ArgumentOutOfRangeException(nameof(age), "Age cannot be negative");
    if (age > 150)
        throw new ArgumentOutOfRangeException(nameof(age), "Age seems unrealistic");
    return age;
}

// This works fine
Console.WriteLine(SetAge(25));    // 25

// This throws an error you created
try
{
    SetAge(-5);
}
catch (ArgumentOutOfRangeException e)
{
    Console.WriteLine($"Error: {e.Message}");
}

// Common built-in exception types to throw
throw new ArgumentNullException(nameof(name), "Name is required");
throw new ArgumentException("Email format is invalid");
throw new InvalidOperationException("Cannot edit a locked record");
throw new NotImplementedException("Feature coming soon");
throw new NotSupportedException("PDF export not available");

Throw/Raise Syntax

Action 🐍 Python ⚡ JavaScript 🔷 C#
Throw an error raise ValueError("msg") throw new Error("msg") throw new Exception("msg")
Keyword raise throw throw
Needs new? No Yes Yes
Re-throw current error raise (bare) throw (bare) or throw error throw; (bare)

⚠️ Python Says "raise," Not "throw"

Just like Python uses except instead of catch, it uses raise instead of throw. Python also doesn't need the new keyword — you just call the exception class directly: raise ValueError("msg"). JavaScript and C# both use throw new.

🎓 Instructor Note: Delivery Guidance

Emphasize that throwing errors is about communication — you're telling future developers (including yourself) exactly what went wrong and why. Show the JavaScript "don't throw primitives" warning as a practical tip. In JS, throw "bad!" technically works but you lose the stack trace and .message property, making debugging much harder. Always throw Error instances. The re-throw syntax (raise / throw / throw; with no argument) is important — it preserves the original stack trace instead of creating a new one.

When to Throw

Not every problem deserves an exception. Here's a practical guide:

Situation Throw an Error? Why
Function receives invalid arguments Yes The caller made a mistake — tell them immediately
Required resource is missing (file, API, DB) Yes Can't continue without it — caller needs to know
Business rule violated (overdraft, expired coupon) Yes Operation shouldn't proceed — protect data integrity
Object is in wrong state for operation Yes E.g., editing after "save," connecting already-connected
Search returns no results No Empty results are normal — return an empty list
User input might be invalid Depends Validate first; only throw if validation is bypassed

💡 The Rule of Thumb

Throw when the caller broke a contract — they passed bad data, called things in the wrong order, or a required external resource failed. Don't throw for situations that are a normal part of program flow, like a search returning zero results or a user choosing to cancel.

Custom Exception Classes

Built-in errors like ValueError or TypeError are generic. When your application has specific problems — like "insufficient funds" or "username taken" — you should create custom exceptions that describe exactly what went wrong.

# Custom exceptions inherit from Exception (or a subclass)
class InsufficientFundsError(Exception):
    """Raised when a withdrawal exceeds the account balance."""
    pass

class AccountLockedError(Exception):
    """Raised when an operation is attempted on a locked account."""
    pass

class InvalidAmountError(ValueError):
    """Raised when a monetary amount is invalid (negative or zero)."""
    pass

# Using custom exceptions
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance
        self.locked = False

    def withdraw(self, amount):
        if self.locked:
            raise AccountLockedError(f"Account for {self.owner} is locked")
        if amount <= 0:
            raise InvalidAmountError(f"Amount must be positive, got {amount}")
        if amount > self.balance:
            raise InsufficientFundsError(
                f"Cannot withdraw ${amount:.2f} — balance is ${self.balance:.2f}"
            )
        self.balance -= amount
        return self.balance

# Catching custom exceptions
account = BankAccount("Alice", 100)

try:
    account.withdraw(150)
except InsufficientFundsError as e:
    print(f"💰 {e}")
# 💰 Cannot withdraw $150.00 — balance is $100.00

try:
    account.withdraw(-50)
except InvalidAmountError as e:
    print(f"❌ {e}")
# ❌ Amount must be positive, got -50
// Custom exceptions extend Error
class InsufficientFundsError extends Error {
    constructor(message) {
        super(message);
        this.name = "InsufficientFundsError";  // Override the name!
    }
}

class AccountLockedError extends Error {
    constructor(message) {
        super(message);
        this.name = "AccountLockedError";
    }
}

class InvalidAmountError extends RangeError {
    constructor(message) {
        super(message);
        this.name = "InvalidAmountError";
    }
}

// Using custom exceptions
class BankAccount {
    constructor(owner, balance = 0) {
        this.owner = owner;
        this.balance = balance;
        this.locked = false;
    }

    withdraw(amount) {
        if (this.locked) {
            throw new AccountLockedError(`Account for ${this.owner} is locked`);
        }
        if (amount <= 0) {
            throw new InvalidAmountError(`Amount must be positive, got ${amount}`);
        }
        if (amount > this.balance) {
            throw new InsufficientFundsError(
                `Cannot withdraw $${amount.toFixed(2)} — balance is $${this.balance.toFixed(2)}`
            );
        }
        this.balance -= amount;
        return this.balance;
    }
}

// Catching custom exceptions
let account = new BankAccount("Alice", 100);

try {
    account.withdraw(150);
} catch (error) {
    if (error instanceof InsufficientFundsError) {
        console.log(`💰 ${error.message}`);
    }
}
// 💰 Cannot withdraw $150.00 — balance is $100.00
// Custom exceptions inherit from Exception (or a subclass)
class InsufficientFundsException : Exception
{
    public InsufficientFundsException(string message) : base(message) { }
}

class AccountLockedException : InvalidOperationException
{
    public AccountLockedException(string message) : base(message) { }
}

class InvalidAmountException : ArgumentException
{
    public InvalidAmountException(string message) : base(message) { }
}

// Using custom exceptions
class BankAccount
{
    public string Owner;
    public decimal Balance;
    public bool Locked;

    public BankAccount(string owner, decimal balance = 0)
    {
        Owner = owner;
        Balance = balance;
        Locked = false;
    }

    public decimal Withdraw(decimal amount)
    {
        if (Locked)
            throw new AccountLockedException($"Account for {Owner} is locked");
        if (amount <= 0)
            throw new InvalidAmountException($"Amount must be positive, got {amount}");
        if (amount > Balance)
            throw new InsufficientFundsException(
                $"Cannot withdraw {amount:C} — balance is {Balance:C}");

        Balance -= amount;
        return Balance;
    }
}

// Catching custom exceptions
var account = new BankAccount("Alice", 100);

try
{
    account.Withdraw(150);
}
catch (InsufficientFundsException e)
{
    Console.WriteLine($"💰 {e.Message}");
}
// 💰 Cannot withdraw $150.00 — balance is $100.00

Custom Exception Syntax

Feature 🐍 Python ⚡ JavaScript 🔷 C#
Base class Exception Error Exception
Minimal definition class MyError(Exception): pass Constructor + this.name Constructor calling : base(msg)
Naming convention SomethingError SomethingError SomethingException

⚠️ JavaScript: Always Set this.name

If you don't set this.name in your custom JS error class, the error will display as "Error" in logs and stack traces instead of "InsufficientFundsError". Python and C# get the class name automatically — JavaScript requires you to set it manually.

💡 C# Convention: "Exception" Suffix

C# uses the suffix Exception (e.g., InsufficientFundsException) while Python and JavaScript use Error (e.g., InsufficientFundsError). This is just a naming convention — follow whichever your language community expects.

🎓 Instructor Note: Delivery Guidance

The bank account is a perfect teaching domain because every student understands withdrawals, deposits, and balances. Walk through the withdraw() method line by line: "First we check if the account is locked — different error. Then we check if the amount is valid — different error. Then we check if there's enough money — different error." Each check uses a different custom exception, which means the caller can handle each situation differently. Compare this to a single generic raise Exception("something went wrong") — the caller would have no idea what happened. Python's minimal syntax (class MyError(Exception): pass) is worth highlighting; it's the simplest custom exception definition across all three languages.

Adding Data to Exceptions

A message string is helpful, but sometimes the caller needs structured data to handle the error properly — like the amount that was short, the account balance, or a specific error code. Custom exceptions can carry any data you want.

class InsufficientFundsError(Exception):
    """Carries the shortage amount so callers can act on it."""
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        self.shortage = amount - balance
        super().__init__(
            f"Cannot withdraw ${amount:.2f} from ${balance:.2f} "
            f"(short ${self.shortage:.2f})"
        )

class ValidationError(Exception):
    """Carries a field name and error code."""
    def __init__(self, field, message, code=None):
        self.field = field
        self.code = code
        super().__init__(f"{field}: {message}")

# Using the rich exception data
def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount

try:
    withdraw(75.00, 100.00)
except InsufficientFundsError as e:
    print(f"Error: {e}")
    print(f"  Balance:  ${e.balance:.2f}")
    print(f"  Requested: ${e.amount:.2f}")
    print(f"  Short by:  ${e.shortage:.2f}")
    # Now you can offer: "Would you like to withdraw $75.00 instead?"

# Error: Cannot withdraw $100.00 from $75.00 (short $25.00)
#   Balance:  $75.00
#   Requested: $100.00
#   Short by:  $25.00

# Validation with error codes
try:
    raise ValidationError("email", "Invalid format", code="INVALID_EMAIL")
except ValidationError as e:
    print(f"Field: {e.field}, Code: {e.code}, Message: {e}")
class InsufficientFundsError extends Error {
    constructor(balance, amount) {
        let shortage = amount - balance;
        super(
            `Cannot withdraw $${amount.toFixed(2)} from $${balance.toFixed(2)} ` +
            `(short $${shortage.toFixed(2)})`
        );
        this.name = "InsufficientFundsError";
        this.balance = balance;
        this.amount = amount;
        this.shortage = shortage;
    }
}

class ValidationError extends Error {
    constructor(field, message, code = null) {
        super(`${field}: ${message}`);
        this.name = "ValidationError";
        this.field = field;
        this.code = code;
    }
}

// Using the rich exception data
function withdraw(balance, amount) {
    if (amount > balance) {
        throw new InsufficientFundsError(balance, amount);
    }
    return balance - amount;
}

try {
    withdraw(75.00, 100.00);
} catch (error) {
    if (error instanceof InsufficientFundsError) {
        console.log(`Error: ${error.message}`);
        console.log(`  Balance:  $${error.balance.toFixed(2)}`);
        console.log(`  Requested: $${error.amount.toFixed(2)}`);
        console.log(`  Short by:  $${error.shortage.toFixed(2)}`);
    }
}

// Validation with error codes
try {
    throw new ValidationError("email", "Invalid format", "INVALID_EMAIL");
} catch (error) {
    console.log(`Field: ${error.field}, Code: ${error.code}`);
}
class InsufficientFundsException : Exception
{
    public decimal Balance { get; }
    public decimal Amount { get; }
    public decimal Shortage => Amount - Balance;

    public InsufficientFundsException(decimal balance, decimal amount)
        : base($"Cannot withdraw {amount:C} from {balance:C} (short {amount - balance:C})")
    {
        Balance = balance;
        Amount = amount;
    }
}

class ValidationException : Exception
{
    public string Field { get; }
    public string? Code { get; }

    public ValidationException(string field, string message, string? code = null)
        : base($"{field}: {message}")
    {
        Field = field;
        Code = code;
    }
}

// Using the rich exception data
static decimal Withdraw(decimal balance, decimal amount)
{
    if (amount > balance)
        throw new InsufficientFundsException(balance, amount);
    return balance - amount;
}

try
{
    Withdraw(75.00m, 100.00m);
}
catch (InsufficientFundsException e)
{
    Console.WriteLine($"Error: {e.Message}");
    Console.WriteLine($"  Balance:  {e.Balance:C}");
    Console.WriteLine($"  Requested: {e.Amount:C}");
    Console.WriteLine($"  Short by:  {e.Shortage:C}");
}

// Validation with error codes
try
{
    throw new ValidationException("email", "Invalid format", "INVALID_EMAIL");
}
catch (ValidationException e)
{
    Console.WriteLine($"Field: {e.Field}, Code: {e.Code}");
}

✅ Why Data-Rich Exceptions Matter

With extra data on the exception, the caller can make smart decisions. Instead of just showing "insufficient funds," the UI could say: "You're $25.00 short. Would you like to withdraw $75.00 instead?" The error code (INVALID_EMAIL) lets the frontend highlight the right field and show the right message — without parsing error strings.

Exception Hierarchies

Just as classes can form inheritance trees, exceptions can too. A hierarchy lets callers choose how specifically they want to handle errors — they can catch the exact type or a broader parent type.

classDiagram class BankError { +message: string } class AccountError { +account_id: string } class TransactionError { +amount: decimal } class InsufficientFundsError { +balance: decimal +shortage: decimal } class DailyLimitExceededError { +limit: decimal +attempted: decimal } class AccountLockedError { +reason: string } class AccountNotFoundError { +account_id: string } BankError <|-- AccountError : extends BankError <|-- TransactionError : extends TransactionError <|-- InsufficientFundsError : extends TransactionError <|-- DailyLimitExceededError : extends AccountError <|-- AccountLockedError : extends AccountError <|-- AccountNotFoundError : extends
# Base error for all banking operations
class BankError(Exception):
    """Base class for all banking exceptions."""
    pass

# Account-related errors
class AccountError(BankError):
    """Base for account-level problems."""
    def __init__(self, account_id, message):
        self.account_id = account_id
        super().__init__(f"Account {account_id}: {message}")

class AccountLockedError(AccountError):
    def __init__(self, account_id, reason="policy violation"):
        self.reason = reason
        super().__init__(account_id, f"Locked ({reason})")

class AccountNotFoundError(AccountError):
    def __init__(self, account_id):
        super().__init__(account_id, "Not found")

# Transaction-related errors
class TransactionError(BankError):
    """Base for transaction-level problems."""
    def __init__(self, amount, message):
        self.amount = amount
        super().__init__(f"Transaction of ${amount:.2f}: {message}")

class InsufficientFundsError(TransactionError):
    def __init__(self, amount, balance):
        self.balance = balance
        self.shortage = amount - balance
        super().__init__(amount, f"Insufficient funds (balance: ${balance:.2f})")

class DailyLimitExceededError(TransactionError):
    def __init__(self, amount, limit):
        self.limit = limit
        super().__init__(amount, f"Exceeds daily limit of ${limit:.2f}")

# --- Catching at different levels of specificity ---

# Specific: handle just insufficient funds
try:
    raise InsufficientFundsError(500, 200)
except InsufficientFundsError as e:
    print(f"Short ${e.shortage:.2f}")

# Medium: handle any transaction error
try:
    raise DailyLimitExceededError(5000, 2000)
except TransactionError as e:
    print(f"Transaction failed: {e}")

# Broad: handle any banking error
try:
    raise AccountLockedError("ACC-123", "fraud investigation")
except BankError as e:
    print(f"Banking error: {e}")
// Base error for all banking operations
class BankError extends Error {
    constructor(message) {
        super(message);
        this.name = "BankError";
    }
}

// Account-related errors
class AccountError extends BankError {
    constructor(accountId, message) {
        super(`Account ${accountId}: ${message}`);
        this.name = "AccountError";
        this.accountId = accountId;
    }
}

class AccountLockedError extends AccountError {
    constructor(accountId, reason = "policy violation") {
        super(accountId, `Locked (${reason})`);
        this.name = "AccountLockedError";
        this.reason = reason;
    }
}

class AccountNotFoundError extends AccountError {
    constructor(accountId) {
        super(accountId, "Not found");
        this.name = "AccountNotFoundError";
    }
}

// Transaction-related errors
class TransactionError extends BankError {
    constructor(amount, message) {
        super(`Transaction of $${amount.toFixed(2)}: ${message}`);
        this.name = "TransactionError";
        this.amount = amount;
    }
}

class InsufficientFundsError extends TransactionError {
    constructor(amount, balance) {
        super(amount, `Insufficient funds (balance: $${balance.toFixed(2)})`);
        this.name = "InsufficientFundsError";
        this.balance = balance;
        this.shortage = amount - balance;
    }
}

// --- Catching at different levels ---
try {
    throw new InsufficientFundsError(500, 200);
} catch (error) {
    if (error instanceof InsufficientFundsError) {
        console.log(`Short $${error.shortage.toFixed(2)}`);
    } else if (error instanceof TransactionError) {
        console.log(`Transaction failed: ${error.message}`);
    } else if (error instanceof BankError) {
        console.log(`Banking error: ${error.message}`);
    }
}

// instanceof checks the ENTIRE chain
let err = new InsufficientFundsError(100, 50);
console.log(err instanceof InsufficientFundsError);  // true
console.log(err instanceof TransactionError);         // true
console.log(err instanceof BankError);                // true
console.log(err instanceof Error);                    // true
// Base error for all banking operations
class BankException : Exception
{
    public BankException(string message) : base(message) { }
}

// Account-related errors
class AccountException : BankException
{
    public string AccountId { get; }
    public AccountException(string accountId, string message)
        : base($"Account {accountId}: {message}")
    { AccountId = accountId; }
}

class AccountLockedException : AccountException
{
    public string Reason { get; }
    public AccountLockedException(string accountId, string reason = "policy violation")
        : base(accountId, $"Locked ({reason})")
    { Reason = reason; }
}

class AccountNotFoundException : AccountException
{
    public AccountNotFoundException(string accountId)
        : base(accountId, "Not found") { }
}

// Transaction-related errors
class TransactionException : BankException
{
    public decimal Amount { get; }
    public TransactionException(decimal amount, string message)
        : base($"Transaction of {amount:C}: {message}")
    { Amount = amount; }
}

class InsufficientFundsException : TransactionException
{
    public decimal Balance { get; }
    public decimal Shortage => Amount - Balance;
    public InsufficientFundsException(decimal amount, decimal balance)
        : base(amount, $"Insufficient funds (balance: {balance:C})")
    { Balance = balance; }
}

// --- Catching at different levels ---

// Specific
try { throw new InsufficientFundsException(500, 200); }
catch (InsufficientFundsException e)
{ Console.WriteLine($"Short {e.Shortage:C}"); }

// Medium
try { throw new InsufficientFundsException(500, 200); }
catch (TransactionException e)
{ Console.WriteLine($"Transaction failed: {e.Message}"); }

// Broad
try { throw new AccountLockedException("ACC-123", "fraud"); }
catch (BankException e)
{ Console.WriteLine($"Banking error: {e.Message}"); }
🎓 Instructor Note: Delivery Guidance

The Mermaid diagram is key here — let students see the tree structure before diving into code. Walk through the catching levels: "If I catch TransactionError, that catches both InsufficientFundsError and DailyLimitExceededError. If I catch BankError, I catch everything." This is exactly the same polymorphism they learned in Lesson 18 — exceptions are just classes, and isinstance/instanceof/is works the same way. Note that JavaScript's single catch block means you use instanceof chains instead of multiple catch blocks — less elegant but equally functional.

Guard Clauses

A guard clause is a check at the top of a function that immediately throws if the input is invalid. Guards make your code cleaner — they eliminate deeply nested if/else blocks by handling the "bad" cases first and letting the "happy path" flow naturally.

# ❌ Without guards — deeply nested "pyramid of doom"
def create_user(username, email, age):
    if username:
        if len(username) >= 3:
            if email:
                if "@" in email:
                    if age:
                        if age >= 13:
                            # FINALLY, the actual logic, buried 6 levels deep
                            return {"username": username, "email": email, "age": age}
                        else:
                            raise ValueError("Must be 13+")
                    else:
                        raise ValueError("Age required")
                else:
                    raise ValueError("Invalid email")
            else:
                raise ValueError("Email required")
        else:
            raise ValueError("Username too short")
    else:
        raise ValueError("Username required")

# ✅ With guards — flat, clear, and readable
def create_user(username, email, age):
    # Guards: check each requirement, bail immediately if invalid
    if not username:
        raise ValueError("Username is required")
    if len(username) < 3:
        raise ValueError("Username must be at least 3 characters")
    if not email:
        raise ValueError("Email is required")
    if "@" not in email:
        raise ValueError("Email must contain @")
    if not age:
        raise ValueError("Age is required")
    if age < 13:
        raise ValueError("Must be at least 13 years old")

    # Happy path — no nesting needed
    return {"username": username, "email": email, "age": age}
// ❌ Without guards — deeply nested
function createUser(username, email, age) {
    if (username) {
        if (username.length >= 3) {
            if (email) {
                if (email.includes("@")) {
                    if (age) {
                        if (age >= 13) {
                            return { username, email, age };
                        } else { throw new RangeError("Must be 13+"); }
                    } else { throw new Error("Age required"); }
                } else { throw new Error("Invalid email"); }
            } else { throw new Error("Email required"); }
        } else { throw new Error("Username too short"); }
    } else { throw new Error("Username required"); }
}

// ✅ With guards — flat, clear, and readable
function createUser(username, email, age) {
    if (!username) throw new Error("Username is required");
    if (username.length < 3) throw new Error("Username must be at least 3 characters");
    if (!email) throw new Error("Email is required");
    if (!email.includes("@")) throw new Error("Email must contain @");
    if (!age) throw new Error("Age is required");
    if (age < 13) throw new RangeError("Must be at least 13 years old");

    // Happy path — no nesting
    return { username, email, age };
}
// ✅ With guards — clean and modern C#
static User CreateUser(string? username, string? email, int? age)
{
    if (string.IsNullOrWhiteSpace(username))
        throw new ArgumentException("Username is required");
    if (username.Length < 3)
        throw new ArgumentException("Username must be at least 3 characters");
    if (string.IsNullOrWhiteSpace(email))
        throw new ArgumentException("Email is required");
    if (!email.Contains('@'))
        throw new ArgumentException("Email must contain @");
    if (age is null)
        throw new ArgumentNullException(nameof(age), "Age is required");
    if (age < 13)
        throw new ArgumentOutOfRangeException(nameof(age), "Must be at least 13");

    // Happy path
    return new User { Username = username, Email = email, Age = age.Value };
}

// C# 11+ also has ArgumentException.ThrowIfNullOrEmpty()
static void QuickGuards(string name, int count)
{
    ArgumentException.ThrowIfNullOrEmpty(name);
    ArgumentOutOfRangeException.ThrowIfNegative(count);
    // ... happy path
}

✅ Guard Clause Benefits

  • Flat code — no nesting pyramid, easy to scan
  • Fail fast — invalid inputs are rejected immediately
  • Happy path is obvious — it's all the code after the guards
  • Each guard is independent — easy to add, remove, or reorder validations

Throw vs. Return

When something goes wrong, should you throw an error or return a special value (like null or an error object)? Both have their place.

Approach Best For Pros Cons
Throw Broken contracts, unrecoverable states Can't be ignored, clear stack trace Interrupts flow, costs performance if frequent
Return null/None "Not found" results Simple, expected by callers Easy to forget to check, null propagation bugs
Return result object Operations that may or may not succeed Forces caller to handle both paths More code to write and consume
# Pattern 1: Throw — for contract violations
def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero")
    return a / b

# Pattern 2: Return None — for "not found" searches
def find_user(users, name):
    for user in users:
        if user["name"] == name:
            return user
    return None  # Not found is a normal outcome

# Pattern 3: Return a result tuple/dict — forces handling
def validate_password(password):
    errors = []
    if len(password) < 8:
        errors.append("Must be at least 8 characters")
    if not any(c.isupper() for c in password):
        errors.append("Must contain an uppercase letter")
    if not any(c.isdigit() for c in password):
        errors.append("Must contain a digit")

    if errors:
        return {"valid": False, "errors": errors}
    return {"valid": True, "errors": []}

# Usage:
result = validate_password("abc")
if not result["valid"]:
    for error in result["errors"]:
        print(f"  ❌ {error}")
# ❌ Must be at least 8 characters
# ❌ Must contain an uppercase letter
# ❌ Must contain a digit
// Pattern 1: Throw — for contract violations
function divide(a, b) {
    if (b === 0) throw new Error("Cannot divide by zero");
    return a / b;
}

// Pattern 2: Return null — for "not found" searches
function findUser(users, name) {
    return users.find(u => u.name === name) || null;
}

// Pattern 3: Return a result object — forces handling
function validatePassword(password) {
    let errors = [];
    if (password.length < 8)
        errors.push("Must be at least 8 characters");
    if (!/[A-Z]/.test(password))
        errors.push("Must contain an uppercase letter");
    if (!/\d/.test(password))
        errors.push("Must contain a digit");

    return { valid: errors.length === 0, errors };
}

let result = validatePassword("abc");
if (!result.valid) {
    result.errors.forEach(err => console.log(`  ❌ ${err}`));
}
// Pattern 1: Throw — for contract violations
static double Divide(double a, double b)
{
    if (b == 0) throw new DivideByZeroException("Cannot divide by zero");
    return a / b;
}

// Pattern 2: TryParse pattern — caller decides how to handle
// This is a very common C# idiom
static bool TryFindUser(List<User> users, string name, out User? found)
{
    found = users.FirstOrDefault(u => u.Name == name);
    return found != null;
}

// Usage:
if (TryFindUser(users, "Alice", out var user))
    Console.WriteLine($"Found: {user.Name}");
else
    Console.WriteLine("User not found");

// Pattern 3: Return a result object
record ValidationResult(bool Valid, List<string> Errors);

static ValidationResult ValidatePassword(string password)
{
    var errors = new List<string>();
    if (password.Length < 8)
        errors.Add("Must be at least 8 characters");
    if (!password.Any(char.IsUpper))
        errors.Add("Must contain an uppercase letter");
    if (!password.Any(char.IsDigit))
        errors.Add("Must contain a digit");
    return new ValidationResult(errors.Count == 0, errors);
}

var result = ValidatePassword("abc");
if (!result.Valid)
    result.Errors.ForEach(e => Console.WriteLine($"  ❌ {e}"));

💡 C#'s TryParse Pattern

C# has a beloved convention: methods that might fail return a bool and put the result in an out parameter. You see this everywhere: int.TryParse(), Dictionary.TryGetValue(), etc. It's the "return" approach baked into the language's DNA.

Exercises

🏋️ Exercise 1: User Registration System

Objective: Build a registration system with custom exceptions:

  • Create custom exceptions: RegistrationError (base), UsernameTakenError, WeakPasswordError, InvalidEmailError
  • WeakPasswordError should list the specific requirements that failed
  • Build a register_user() function that validates and either succeeds or throws the appropriate custom exception
  • Maintain a list of existing users to check for duplicate usernames
  • Demonstrate catching at different levels: specific error types and the base RegistrationError
✅ Solution
class RegistrationError(Exception):
    """Base for all registration problems."""
    pass

class UsernameTakenError(RegistrationError):
    def __init__(self, username):
        self.username = username
        super().__init__(f"Username '{username}' is already taken")

class WeakPasswordError(RegistrationError):
    def __init__(self, failures):
        self.failures = failures
        msg = "Password too weak: " + "; ".join(failures)
        super().__init__(msg)

class InvalidEmailError(RegistrationError):
    def __init__(self, email):
        self.email = email
        super().__init__(f"Invalid email address: '{email}'")

# Existing users database (simulated)
existing_users = ["alice", "bob", "charlie"]

def register_user(username, email, password):
    # Guard clauses with custom exceptions
    if not username or len(username) < 3:
        raise RegistrationError("Username must be at least 3 characters")

    if username.lower() in existing_users:
        raise UsernameTakenError(username)

    if not email or "@" not in email or "." not in email.split("@")[-1]:
        raise InvalidEmailError(email)

    # Password validation
    pw_failures = []
    if len(password) < 8:
        pw_failures.append("at least 8 characters")
    if not any(c.isupper() for c in password):
        pw_failures.append("one uppercase letter")
    if not any(c.islower() for c in password):
        pw_failures.append("one lowercase letter")
    if not any(c.isdigit() for c in password):
        pw_failures.append("one digit")
    if pw_failures:
        raise WeakPasswordError(pw_failures)

    # Success!
    existing_users.append(username.lower())
    return {"username": username, "email": email, "status": "active"}

# --- Test specific catches ---
test_cases = [
    ("alice", "alice@test.com", "StrongPass1"),    # Username taken
    ("newuser", "bad-email", "StrongPass1"),        # Invalid email
    ("newuser", "new@test.com", "weak"),            # Weak password
    ("newuser", "new@test.com", "StrongPass1"),     # Success!
]

for username, email, password in test_cases:
    try:
        user = register_user(username, email, password)
        print(f"✅ Registered: {user['username']}")
    except UsernameTakenError as e:
        print(f"👤 {e}")
    except InvalidEmailError as e:
        print(f"📧 {e}")
    except WeakPasswordError as e:
        print(f"🔒 {e}")
        print(f"   Missing: {', '.join(e.failures)}")
    except RegistrationError as e:
        print(f"❌ Registration failed: {e}")
class RegistrationError extends Error {
    constructor(message) {
        super(message);
        this.name = "RegistrationError";
    }
}

class UsernameTakenError extends RegistrationError {
    constructor(username) {
        super(`Username '${username}' is already taken`);
        this.name = "UsernameTakenError";
        this.username = username;
    }
}

class WeakPasswordError extends RegistrationError {
    constructor(failures) {
        super("Password too weak: " + failures.join("; "));
        this.name = "WeakPasswordError";
        this.failures = failures;
    }
}

class InvalidEmailError extends RegistrationError {
    constructor(email) {
        super(`Invalid email address: '${email}'`);
        this.name = "InvalidEmailError";
        this.email = email;
    }
}

let existingUsers = ["alice", "bob", "charlie"];

function registerUser(username, email, password) {
    if (!username || username.length < 3)
        throw new RegistrationError("Username must be at least 3 characters");
    if (existingUsers.includes(username.toLowerCase()))
        throw new UsernameTakenError(username);
    if (!email || !email.includes("@") || !email.split("@")[1]?.includes("."))
        throw new InvalidEmailError(email);

    let failures = [];
    if (password.length < 8) failures.push("at least 8 characters");
    if (!/[A-Z]/.test(password)) failures.push("one uppercase letter");
    if (!/[a-z]/.test(password)) failures.push("one lowercase letter");
    if (!/\d/.test(password)) failures.push("one digit");
    if (failures.length) throw new WeakPasswordError(failures);

    existingUsers.push(username.toLowerCase());
    return { username, email, status: "active" };
}

// Test
let tests = [
    ["alice", "alice@test.com", "StrongPass1"],
    ["newuser", "bad-email", "StrongPass1"],
    ["newuser", "new@test.com", "weak"],
    ["newuser", "new@test.com", "StrongPass1"],
];

for (let [username, email, password] of tests) {
    try {
        let user = registerUser(username, email, password);
        console.log(`✅ Registered: ${user.username}`);
    } catch (error) {
        if (error instanceof UsernameTakenError) {
            console.log(`👤 ${error.message}`);
        } else if (error instanceof InvalidEmailError) {
            console.log(`📧 ${error.message}`);
        } else if (error instanceof WeakPasswordError) {
            console.log(`🔒 ${error.message}`);
        } else if (error instanceof RegistrationError) {
            console.log(`❌ ${error.message}`);
        }
    }
}
class RegistrationException : Exception
{
    public RegistrationException(string msg) : base(msg) { }
}

class UsernameTakenException : RegistrationException
{
    public string Username { get; }
    public UsernameTakenException(string username)
        : base($"Username '{username}' is already taken")
    { Username = username; }
}

class WeakPasswordException : RegistrationException
{
    public List<string> Failures { get; }
    public WeakPasswordException(List<string> failures)
        : base("Password too weak: " + string.Join("; ", failures))
    { Failures = failures; }
}

class InvalidEmailException : RegistrationException
{
    public string Email { get; }
    public InvalidEmailException(string email)
        : base($"Invalid email: '{email}'")
    { Email = email; }
}

List<string> existingUsers = new() { "alice", "bob", "charlie" };

Dictionary<string, string> RegisterUser(string username, string email, string password)
{
    if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
        throw new RegistrationException("Username must be at least 3 characters");
    if (existingUsers.Contains(username.ToLower()))
        throw new UsernameTakenException(username);
    if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
        throw new InvalidEmailException(email);

    var failures = new List<string>();
    if (password.Length < 8) failures.Add("at least 8 characters");
    if (!password.Any(char.IsUpper)) failures.Add("one uppercase letter");
    if (!password.Any(char.IsLower)) failures.Add("one lowercase letter");
    if (!password.Any(char.IsDigit)) failures.Add("one digit");
    if (failures.Count > 0) throw new WeakPasswordException(failures);

    existingUsers.Add(username.ToLower());
    return new() { ["username"] = username, ["status"] = "active" };
}

try { RegisterUser("alice", "a@b.com", "StrongPass1"); }
catch (UsernameTakenException e) { Console.WriteLine($"👤 {e.Message}"); }

try { RegisterUser("new", "bad", "StrongPass1"); }
catch (InvalidEmailException e) { Console.WriteLine($"📧 {e.Message}"); }

try { RegisterUser("new", "n@b.com", "weak"); }
catch (WeakPasswordException e) { Console.WriteLine($"🔒 {e.Message}"); }

🏋️ Exercise 2: Online Store Order System

Objective: Build an order processing system with a custom error hierarchy:

  • OrderError (base) → OutOfStockError (with product, requested, available) and InvalidQuantityError
  • A product inventory dictionary with stock levels
  • An order_item(product, quantity) function that validates and processes orders
  • A process_cart(items) function that processes multiple items, collecting errors for each failed item instead of stopping at the first error
  • Display a summary: which items succeeded, which failed, and why
✅ Solution
class OrderError(Exception):
    """Base for all order problems."""
    pass

class OutOfStockError(OrderError):
    def __init__(self, product, requested, available):
        self.product = product
        self.requested = requested
        self.available = available
        super().__init__(
            f"'{product}': requested {requested}, only {available} available"
        )

class InvalidQuantityError(OrderError):
    def __init__(self, product, quantity):
        self.product = product
        self.quantity = quantity
        super().__init__(f"'{product}': invalid quantity {quantity}")

# Inventory
inventory = {
    "Widget":  {"price": 9.99,  "stock": 50},
    "Gadget":  {"price": 24.99, "stock": 3},
    "Gizmo":   {"price": 14.99, "stock": 0},
    "Doohickey": {"price": 4.99, "stock": 100},
}

def order_item(product, quantity):
    if product not in inventory:
        raise OrderError(f"Product '{product}' not found")
    if quantity <= 0 or not isinstance(quantity, int):
        raise InvalidQuantityError(product, quantity)
    available = inventory[product]["stock"]
    if quantity > available:
        raise OutOfStockError(product, quantity, available)

    inventory[product]["stock"] -= quantity
    cost = inventory[product]["price"] * quantity
    return {"product": product, "quantity": quantity, "cost": cost}

def process_cart(items):
    successes = []
    failures = []

    for product, quantity in items:
        try:
            result = order_item(product, quantity)
            successes.append(result)
        except OutOfStockError as e:
            failures.append({"product": product, "error": str(e), "type": "stock"})
        except InvalidQuantityError as e:
            failures.append({"product": product, "error": str(e), "type": "quantity"})
        except OrderError as e:
            failures.append({"product": product, "error": str(e), "type": "general"})

    return successes, failures

# Process a cart
cart = [
    ("Widget", 5),      # ✅ Success
    ("Gizmo", 2),       # ❌ Out of stock (0 available)
    ("Gadget", 10),     # ❌ Out of stock (only 3)
    ("Flux Cap", 1),    # ❌ Product not found
    ("Widget", -3),     # ❌ Invalid quantity
    ("Doohickey", 20),  # ✅ Success
]

successes, failures = process_cart(cart)

print("=== Order Summary ===\n")
print("✅ Successful:")
total = 0
for item in successes:
    print(f"   {item['product']} x{item['quantity']} = ${item['cost']:.2f}")
    total += item["cost"]
print(f"   Total: ${total:.2f}\n")

print("❌ Failed:")
for fail in failures:
    print(f"   {fail['error']}")
class OrderError extends Error {
    constructor(message) { super(message); this.name = "OrderError"; }
}

class OutOfStockError extends OrderError {
    constructor(product, requested, available) {
        super(`'${product}': requested ${requested}, only ${available} available`);
        this.name = "OutOfStockError";
        this.product = product;
        this.requested = requested;
        this.available = available;
    }
}

class InvalidQuantityError extends OrderError {
    constructor(product, quantity) {
        super(`'${product}': invalid quantity ${quantity}`);
        this.name = "InvalidQuantityError";
        this.product = product;
        this.quantity = quantity;
    }
}

let inventory = {
    Widget:  { price: 9.99,  stock: 50 },
    Gadget:  { price: 24.99, stock: 3 },
    Gizmo:   { price: 14.99, stock: 0 },
    Doohickey: { price: 4.99, stock: 100 },
};

function orderItem(product, quantity) {
    if (!inventory[product]) throw new OrderError(`Product '${product}' not found`);
    if (quantity <= 0 || !Number.isInteger(quantity))
        throw new InvalidQuantityError(product, quantity);
    if (quantity > inventory[product].stock)
        throw new OutOfStockError(product, quantity, inventory[product].stock);

    inventory[product].stock -= quantity;
    return { product, quantity, cost: inventory[product].price * quantity };
}

function processCart(items) {
    let successes = [], failures = [];
    for (let [product, qty] of items) {
        try {
            successes.push(orderItem(product, qty));
        } catch (error) {
            failures.push({ product, error: error.message, type: error.name });
        }
    }
    return { successes, failures };
}

let cart = [["Widget", 5], ["Gizmo", 2], ["Gadget", 10],
            ["Flux Cap", 1], ["Widget", -3], ["Doohickey", 20]];

let { successes, failures } = processCart(cart);
console.log("✅ Successful:");
successes.forEach(s => console.log(`   ${s.product} x${s.quantity} = $${s.cost.toFixed(2)}`));
console.log("❌ Failed:");
failures.forEach(f => console.log(`   ${f.error}`));
class OrderException : Exception
{ public OrderException(string msg) : base(msg) { } }

class OutOfStockException : OrderException
{
    public string Product { get; }
    public int Requested { get; }
    public int Available { get; }
    public OutOfStockException(string product, int requested, int available)
        : base($"'{product}': requested {requested}, only {available} available")
    { Product = product; Requested = requested; Available = available; }
}

class InvalidQuantityException : OrderException
{
    public InvalidQuantityException(string product, int qty)
        : base($"'{product}': invalid quantity {qty}") { }
}

var inventory = new Dictionary<string, (decimal Price, int Stock)>
{
    ["Widget"]    = (9.99m,  50),
    ["Gadget"]    = (24.99m, 3),
    ["Gizmo"]     = (14.99m, 0),
    ["Doohickey"] = (4.99m,  100),
};

(string Product, int Qty, decimal Cost) OrderItem(string product, int qty)
{
    if (!inventory.ContainsKey(product))
        throw new OrderException($"Product '{product}' not found");
    if (qty <= 0)
        throw new InvalidQuantityException(product, qty);
    var item = inventory[product];
    if (qty > item.Stock)
        throw new OutOfStockException(product, qty, item.Stock);
    inventory[product] = (item.Price, item.Stock - qty);
    return (product, qty, item.Price * qty);
}

var cart = new[] { ("Widget", 5), ("Gizmo", 2), ("Gadget", 10),
                   ("Doohickey", 20) };
foreach (var (product, qty) in cart)
{
    try
    {
        var result = OrderItem(product, qty);
        Console.WriteLine($"✅ {result.Product} x{result.Qty} = {result.Cost:C}");
    }
    catch (OrderException e)
    {
        Console.WriteLine($"❌ {e.Message}");
    }
}
🎓 Instructor Note: Delivery Guidance

Exercise 1 (Registration) directly applies the lesson concepts: custom hierarchy, data-rich exceptions, and guard clauses. Exercise 2 (Online Store) adds an important real-world pattern: collecting errors instead of stopping at the first one. In a shopping cart, you want to show the user all the problems at once, not one at a time. Walk through the process_cart() function and point out how try/catch inside a loop lets each item fail independently. Challenge fast students: add a PaymentError to Exercise 2 that captures a payment decline reason and error code from a simulated payment gateway.

Summary

🎉 Key Takeaways

  • raise/throw creates errors explicitly — Python uses raise, JS and C# use throw new
  • Custom exception classes extend built-in error types — name them SomethingError (Python/JS) or SomethingException (C#)
  • Data-rich exceptions carry context (amounts, codes, field names) — callers can make smart recovery decisions
  • Exception hierarchies let callers choose specificity — catch the exact type or a broader parent
  • Guard clauses validate inputs at function entry — flat code, fail fast, clear happy path
  • Throw vs. return: throw for contract violations, return null/result objects for expected outcomes
  • JavaScript needs this.name set manually in custom error constructors

🚀 What's Next?

You can now catch errors, throw errors, and create your own error types. But what about finding bugs before they become errors? In the next lesson, we'll cover debugging strategies — the tools and techniques for tracking down problems in your code.

🎯 Quick Check

Question 1: What keyword does Python use to throw an error?

Question 2: What must you always set in a JavaScript custom error class constructor?

Question 3: What is a guard clause?