try and catch work with any class that extends Exception, and the standard library gives you a few ready-made types (ListException, DmlException, QueryException). When you want the catch site to recognise a problem that is specific to your own code, build a small subclass of Exception and throw instances of it instead.
A custom exception is a class with one job: carry information about a specific failure. Most custom exceptions need nothing more than a constructor that forwards a message up to the parent class:
public class InvalidAgeException extends Exception {
}
That is enough to write throw new InvalidAgeException('Age cannot be negative: -3');. The parent Exception constructor stores the message, so e.getMessage() returns the string you passed in at the throw site.
When the message has to include the value that caused the problem, give the class a constructor that builds the message itself. That keeps the throw site short and keeps the wording consistent:
public class InvalidAgeException extends Exception {
public InvalidAgeException(Integer age) {
this.setMessage('Age cannot be negative: ' + age);
}
}
Now a caller just writes throw new InvalidAgeException(-3); and the class puts the wording together once, in one place.
catch works the same way as with built-in types. Listing the custom class first makes the catch block specific to your problem and lets you give a clearer response than catch (Exception e):
try {
validateAge(ageInput);
} catch (InvalidAgeException e) {
System.debug('Validation rejected: ' + e.getMessage());
}
Custom exceptions give a name to a problem. The catch site knows exactly what went wrong, log messages are easier to read, and tests can assert that the right exception was thrown for the right input. When the same validation rule shows up in more than one place, defining the exception once keeps the wording and the catch logic consistent across the codebase.