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.
Using assertions (and errors) to narrow typescript types
One new feature as of TypeScript 3.7 is the asserts
keyword. 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.
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)
}
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 benull
.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
Was this helpful?