Errors

Example code: src/lib/errors

But what if something goes wrong?

In the case of our example code, simulating a library of books, loaning a book can throw all kinds of exceptions:

  • the customer might not yet have a library card

  • the book being scanned might not belong to the library

  • the customer might already have reached their loan limit

  • the customer might have outstanding fines

  • et cetera

When this happens, what we'd like is for our application to intercept the error and respond appropriately to the user, e.g. with a human readable error message.

To do this we need a set of Error classes.

Which errors should I define in my lib?

Only errors that are to do with the domain itself - either invalid operations or entities in invalid state. Errors thrown by things in context should be defined in the adapter layer.

The point is that your lib/adapters module represents all the errors that can be thrown by the library.

Extending JavaScript errors

In JavaScript you can extend the native Error class to include additional properties and customise the message property. This can help you pass useful information to the user and any error reporting system you have in place.

Let's look at a custom error in our Library project:

export class UserLoanLimitExceeded extends Error {
  public invalidOperationErr = true

  constructor(public userId: UUID) {
    super(`User with ID ${userId} has exceeded their loan limit`)
  }
}

You'll notice several things:

Firstly, the invalidOperationErr property is there so that downsteam code knows it can report the error to the user, who has just attempted something impossible. You always want to opt in to making errors public, as unfiltered errors can reveal a lot of information to malicious users.

Secondly, the userId property is there so that any logging or reporting can capture it.

And finally, there's a custom message, formatted with the user's ID. This can be used as a more human readable error message.

What fields and customisations you make are entirely up to you. None are mandated by this architecture. The important thing is to think carefully about what reporting you need and how exceptions should be presented to the user. Once you know that, the question of which fields you need will answer itself.

Using assertions (and errors) to narrow typescript types

One new feature as of TypeScript 3.7 is the assertskeyword. This lets you tag a function as asserting than an input has a particular type.

This is really helpful when a context function can return a type or null, but to be valid it really has to be defined. For instance:

export async function loanBook (ctx: Context, loanInput: LoanInput): Promise<Loan> {
  ...

  const user = await userStore.find(loanInput.userId)
  assertUser(user, loanInput.userId)

  ...
}

function assertUser (user: User | null, id: UUID): asserts user is User {
  if (!user) throw new UserDoesNotExist(id)
}

In this example, the user store can return a user or null. But we don't want to log a loan by a 'null' user. By calling assertUser we can both throw an error in this case, and tell TypeScript that they type of user has gone from User | null to just User. This means you won't have to do any annoying casts or ! operators.

Assertions let you transform a (validValue | invalidValue) -> (validValue | throw err)

An aside: assertions beyond null checks

Imagine you have a system for managing magazine subscriptions, with an operation to upgradeSubscription. How do you handle someone upgrading a subscription that's been cancelled?

One way is to encode two types:

type Subscription =
  | ActiveSubscription
  | CancelledSubscription
  
type ActiveSubscription = {
  tag: 'activeSubscription',
  paymentMethod: UUID,
  ...
}

type CancelledSubscription = {
  tag: 'cancelledSubscription',
  paymentMethod: null,
  ...
}

function getSubscription () : Subscription {...}

And write an assertion:

function assertActive (sub: Subscription): asserts sub is ActiveSubscription {
  if (sub.tag !== 'activeSubscription') throw new SubscriptionNotActive(sub)
}

Now we can use our assertion and type system to make illegal upgrades impossible to represent without causing a TypeScript error:

function upgrade (ctx: Ctx, sub: Subscription) {
  assertActive(sub)
  
  // If charge() won't accept null,
  // TypeScript will complain if we don't assertActive
  charge(sub.paymentMethod)
}

Using types to express not just that a given data structure is correct, but that its semantics are correct, is called using invariants.

Putting it together: a practical example

Now we can use all these ideas to write the full 'loan book' operation:

export async function loanBook (ctx: Context, loanInput: LoanInput): Promise<Loan> {
  const {
    backend: { userStore, bookStore, loanStore },
    events
  } = ctx

  const user = await userStore.find(loanInput.userId)
  assertUser(user, loanInput.userId)

  const book = await bookStore.find(loanInput.bookId)
  assertBook(book, loanInput.bookId)

  const existingLoan = await loanStore.getLoan(book)
  if (existingLoan) {
    throw new BookAlreadyLoaned(loanInput.bookId)
  }

  const userLoans = await loanStore.getUserLoans(user)
  if (userLoans.length > 3) {
    throw new UserLoanLimitExceeded(loanInput.userId)
  }

  const loan = await loanStore.takeLoan({
    bookId: book.id,
    userId: user.id
  })

  await events.onLoanMade({
    bookName: book.name
  })

  return loan
}

function assertUser (user: User | null, id: UUID): asserts user is User {
  if (!user) throw new UserDoesNotExist(id)
}

function assertBook (book: Book | null, id: UUID): asserts book is Book {
  if (!book) throw new BookDoesNotExist(id)
}
  • The function now checks the operation is valid, and throws typed, reportable errors if the user attempts to make a nonsensical loan.

  • Assertion functions mean that we can e.g. get the book.id without TypeScript complaining that the book could be null.

  • We can use the invalidOperationErr property to tell whether an error is coming from the operation (reportable) or the context (not to be reported).

Last updated