Throw away your try/catch

An exploration of error handling strategies

Mihail Mikov

Staff Enginner, SumUp

18.03.2025

About me

2

do you even javascript

3

Overview

Error handling is a question of reliability. Let's assume 2 main types of approaches:

This presentation focuses on the latter

4

Many types of safety

There are many types of safety in computer science.

5

Let's fix some bugs!

6

Try/Catch in JavaScript

A try/catch block is the main mechanism for handling exceptions in JavaScript

try {
  doSomethingThatMightThrow();
} catch (error) {
  console.error("Error caught:", error.message);
  // assume we are handling the error properly
} finally {
  cleanup();
}
7

Problems with try/catch

8

How smart devs avoid the problem

9

Problem: hidden / confusing control flow

The actual path of execution might be confusing...

function outer() {
  try {
    middle(); // 1
  } catch (e) {
    // 4 - Execution jumps here, skipping the rest of middle() and inner()
  }
}

function middle() {
  inner(); // 2
  console.log("PROBLEM: This won't execute if inner() throws"); // never!
}

function inner() {
  doSomethingThatMightThrow(); // 3
}
10

Problem: Hiding errors

You might never find out an error has actually occrued

function outer() {
  try {
    inner();
  } catch (e) {
    // PROBLEM: we'll never hit this block
  }
}

function inner() {
  try {
    doSomethingThatMightThrow()
  }  catch (e) {
    // Don't worry, it's fine..
  }
}
11

Great plan, but flawed

12

Problem: The single catch block

This coding style is very popular, but is actually problematic

function showDashboard() {
  try {
    const profile = getData();
    const stats = getStats(profile.id);
    renderDashboard(profile, stats);
  } catch (e) {
    // PROBLEM: this block handles too many different errors
  }
}
13

Problem: Multiple catch blocks

We can handle different errors in specific ways, but that is still doesn't prevent aforementioned issues

function showDashboard() {
  try {
    const profile = getData();
    const stats = getStats(profile.id);
    renderDashboard(profile, stats);
  } catch (e: DataError) {
    // handle data error
  } catch (e: StatsError) {
    // handle stats error
  } catch (e: RenderError) {
    // handle render error
  } catch (e) {
    // handle any unexpected errors
  }
}
14

Problem: Handle each error individually

This is most correct, but it looks messy and tedious

let data: Data;
try {
  profile = getData();
catch (e) {
  data = useDefaultData();
}

let stats: Stats;
try {
  stats = getStats(profile.id);
} catch (e) {
  stats = useDefaultStats();
}

try {
  renderDashboard(profile, stats);
} catch (e) {
  // handle rendering error
}
15

Problem: Unknown failure modes

try {
  doSomething();
} catch (error) {
  // QUESTION: What types of errors are we expecting here?
}

Even if we handle each error site individually, neither JS nor TS allows us to know what types of errors a function might generate

16

Potential solution: Exceptions in type declarations (Java)

public void readFile(String path) throws IOException, FileNotFoundException {
  // Method implementation
}

try {
  readFile("config.txt");
} catch (FileNotFoundException e) {
  System.err.println("Config file not found: " + e.getMessage());
} catch (IOException e) {
  System.err.println("Error reading config: " + e.getMessage());
}

This looks ok, but is actually incomplete - any function in Java can also throw a RuntimeException

17

Billion dollar mistake

18

Promises & rejections

Exceptions are sync; rejections - async.

They have a lot of simularities, but also many differences

fetch('https://api.example.com/data')
  .then(response => {
    if (!response.ok) {
      // here we can either throw or reject - what's the difference?
      /* (A) */ throw new Error(`HTTP error! Status: ${response.status}`);
      /* (B) */ return Promise.reject(`HTTP error! Status: ${response.status}`);
    }
    return response.json();
  })
  .then(resp => transformResponse(resp))
  .then(data => useData(data))
  .catch(error => console.error("PROBLEM: Same issue as with the single catch block"));
19

Promises & exceptions

Additionally there is a another hidden problem - fetch itself might throw

try {
  fetch('https://api.example.com/data')
    .then(parseResponse)
    .then(validateData)
    .then(useData)
    .catch(error => log.error("PROBLEM: only ASYNC errors will be caught here"));
} catch (e) {
    // PROBLEM: only handle SYNC errors generated from fetch will be caught here
}

Mixing promises with sync code can easily result in uncaught exceptions or unhandled promises

20

Promises: chain ordering

The order of thens and catches is very important

fetch('https://api.example.com/data')
  .then(parseResponse)
  .catch(error => {
    log.error("Only handles errors from parseResponse")
    // if we return, the then chain continues
    // if we throw / reject - we jump to the next catch
  })
  .then(validateData)
  .then(useData)
  .catch(error => log.error("Handles errors from either validateData or useData"));

A strategic catch in the middle of a promise can be very useful for fallbacks and recovery

21

Async error

22

Promise.all vs Promise.allSettled

Promise.all([
  fetch('/api/users'),
  fetch('/api/products'),
]).then(([usersRes, productsRes]) => {
  // ALL succeeded
}).catch(error => {
  // ANY one failed!!
});

Promise.allSettled([
  fetch('/api/users'),
  fetch('/api/products'),
]).then(results => {
  // results is an array of objects with status 'fulfilled' or 'rejected'
  results.forEach(result => {
    if (result.status === 'fulfilled') {
      console.log('Success:', result.value);
    } else {
      console.log('Error:', result.reason);
    }
  });
});
23

Async/Await in JavaScript

Solves at least one problem - both sync and async errors are handled the same way

async function fetchUserData(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    const userData = await response.json();
    return userData;
  } catch (error) {
    console.error('Error fetching user data:', error);
  }
}

fetchUserData(123)
  .catch(error => console.error("All errors get converted to rejections in async functions"))
24

Errors as values

25

Trigger warning!

We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.

-- from the Golang FAQ

26

Node.js Callbacks Pattern

Who remembers good old callback hell?

fs.readFile('file.txt', (err, data) => {
  if (err) {
    console.error('Error reading file:', err);
    return;
  }

  anotherAsyncFunc(data, (err, result) => {
    if (err) {
      console.error("Error processing data", err);
      return;
    }

    // and so on
  })
});
27

Go's Error Handling

func LoadJsonConfig(path string) (*Config, error) {
    data, err := ioutil.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("reading config: %w", err)
    }

    var cfg Config
    err = json.Unmarshal(dat, &cfg)
    if err != nil {
      return nil, fmt.Errorf("parsing config: %w", err)
    }

    return &cfg, nil
}

data, err := LoadJsonConfig("config.json")
if err != nil {
    // we still need to explicitly distinguish each failure mode
    log.Fatalf("Failed to load config: %v", err)
}
28

Errors as values

29

Zig's Error Handling

Zig implements some nice syntax sugar on top of a model similar to go's

fn readValue() !u32 {
    const file = try std.fs.cwd().openFile("data.txt", .{});
    defer file.close();

    var buffer: [10]u8 = undefined;
    const size = try file.read(&buffer);

    return std.fmt.parseUnsigned(u32, buffer[0..size], 10);
}

fn main() !void {
    const value = readValue() catch |err| {
        std.debug.print("Error: {}\n", .{err});
        return;
    };
    std.debug.print("Value: {}\n", .{value});
}
30

Error handling with monads

31

Rust's Result and Option Types

fn read_config_file(path: &str) -> Result<Config, io::Error> {
    let file = File::open(path)?; // ? operator propagates errors
    let config: Config = serde_json::from_reader(file)?;
    Ok(config)
}

fn find_user(id: UserId) -> Option<User> {
    if id.is_valid() {
        Some(User::new(id))
    } else {
        None
    }
}

match read_config_file("config.json") {
    Ok(config) => println!("Config loaded: {}", config.name),
    Err(e) => eprintln!("Failed to load config: {}", e),
}
32

endofunctors

33

OCaml's Error Handling

(* Using Result type *)
let read_file filename =
  try
    let channel = open_in filename in
    let content = really_input_string channel (in_channel_length channel) in
    close_in channel;
    Ok content
  with
  | Sys_error msg -> Error ("System error: " ^ msg)
  | _ -> Error "Unknown error while reading file"

(* Pattern matching on result *)
match read_file "data.txt" with
| Ok content -> Printf.printf "File content: %s\n" content
| Error msg -> Printf.eprintf "Error: %s\n" msg
34

Algebraic Effects

35

Algebraic Effects: Example

Note: This is psudo code!

function getData() {
  // NOTE: this is NOT possible in JavaScript
  perform log.Info("Logging can be handled as an effect")

  // internally get might perform a throw RequestError
  const data = perform http.Get("http://some.url/")

  return data
}

do {
  const data = getData();
} handle http.Get {
  // actual implementation of the get effect
  resume with data; // Continue execution with the returned value
} handle log.Info {
  *// actual implementation of logging
} handle RequestError {
  // as with throw catch, the first handle block up the stack "catches" the operation
}
36

Algebraic Effects: Explanation

37

Algebraic defects

38

Error Handling in HTTP Request Handlers

This is also an example of the continuation-passing style

app.get('/api/users/:id', async (req, res, next) => {
  try {
    const userId = req.params.id;
    const user = await database.findUser(userId);
    
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    
    res.json(user);
  } catch (error) {
    // Pass to error-handling middleware
    next(error);
  }
});

app.use((err, req, res, next) => {
  console.error('API Error:', err);
  res.status(500).json({ error: 'Internal server error' });
});
39

Cascading Errors Across Service Boundaries

A lot can go wrong when stringing many services together...

async function getUserProfile(userId) {
  try {
    // Call user service
    const user = await userService.getUser(userId);
    
    // Call billing service
    const billing = await billingService.getBillingInfo(userId);
    
    // Call preferences service
    const preferences = await preferencesService.getPreferences(userId);
    
    return { user, billing, preferences };
  } catch (error) {
    if (error.service === 'billing') {
      // Handle billing service failure gracefully
      return { user, preferences, billing: { status: 'unavailable' } };
    }
    throw error; // Re-throw other errors
  }
}
40

There was an error far away from here

41

Cascading Errors Across Service Boundaries

42

Deadline vs timeout based expiry of requests

Giving a timeout to a function that calls multiple underlying services, doesn't make it clear whether the timeout applies for all or each.

 // Using fetch with a timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout

fetch('https://api.example.com/data', { 
  signal: controller.signal 
})

--- vs ---

 // Using a deadline approach (NO BUILT-IN SUPPORT, YET)
makeMultipleRequests({ deadline: Date.now() + 5000 })
43

Circuit Breaker Pattern

const breaker = new CircuitBreaker({
  service: 'payments-api',
  failureThreshold: 5,
  resetTimeout: 30000
});

async function processPayment(order) {
  try {
    return await breaker.execute(() => paymentsApi.process(order));
  } catch (error) {
    if (error.type === 'CIRCUIT_OPEN') {
      return await fallbackPaymentProcess(order);
    }
    throw error;
  }
}
44

Encapsulating Behavior in Error Objects

class QueryError extends Error {
  constructor(message, query) {
    super(message);
    this.type = 'DatabaseError';
    this.query = query;
  }
  
  report() {
    console.error(`Query Error: ${this.query}`);
    monitoring.captureException(this);
    this.report() = () => {}; // ensure that the error is reported at most once
  }
  
  alert() {
    if (this.code >= 500) {
      alerting.sendAlert({
        level: 'critical',
        service: 'database',
        details: this
      });
    }
  }
}
45

Encapsulating Behavior in Error Objects: Usage

Moves the knowledge of how an error should be handled inside the error itself.

try {
  db.select(`SELECT ...`)
} catch (error) {
  if (error instanceof QueryError) {
    if (error.isRetryable()) {
      // handle retry
    }
    // the caller doesn't always know whether they should log and/or alert
    error.report();
    error.alert();
  }
}
46

Advice for better error handling

None the strategies we've looked at here take into account purely logical errors!

"1" + 1 == 2 // false
47

just let it fail

48

Conclusion

49

Useful resources

50

moss poetry

51

Thank you!

Mihail Mikov

Staff Enginner, SumUp

18.03.2025