Created
January 31, 2023 02:00
-
-
Save theScottyJam/084c631adc5da823e4672f0d97287cc1 to your computer and use it in GitHub Desktop.
Example of using the return-your-exceptions pattern
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { User } from './userManager'; | |
export const users: Map<string, User> = new Map(Object.entries({ | |
'1': { | |
id: '1', | |
username: 'admin', | |
permissions: ['VIEW', 'UPDATE'], | |
}, | |
'2': { | |
id: '2', | |
username: 'bob', | |
permissions: ['VIEW'], | |
age: 7, | |
}, | |
'3': { | |
id: '3', | |
username: 'sally', | |
permissions: [], | |
age: 9, | |
}, | |
})); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as userManager from './userManager'; | |
type Permission = userManager.Permission; | |
type User = userManager.User; | |
type GetUsernameOpts<Fallback> = { userIdDoingRequest: string, fallback: Fallback }; | |
type GetUsernameExits<Fallback> = { type: 'ok', value: string | Fallback } | { type: 'unauthorized' } | |
function assert(condition: boolean, message = 'This program unexpectedly ended up in a bad state.'): asserts condition { | |
if (!condition) { | |
throw new Error(message); | |
} | |
} | |
function getUsername<Fallback>(userId: string, { userIdDoingRequest, fallback }: GetUsernameOpts<Fallback>): GetUsernameExits<Fallback> { | |
const maybeUsername = userManager.getProperty(userId, 'username', { userIdDoingRequest }); | |
if (maybeUsername.type === 'notFound') { | |
return { type: 'ok', value: fallback }; | |
} else if (maybeUsername.type === 'unauthorized') { | |
return maybeUsername; | |
} else if (maybeUsername.type === 'missingProp') { | |
// getProperty() returned missingProp as an exception that we could choose to handle. | |
// But, since the 'username' field being passed in is hard-coded and usernames are a required field, | |
// we know this exception should never happen, so it it does, we throw it as an error here, indicating that | |
// there's a bug in the code. | |
throw new Error(`Missing Prop: ${maybeUsername.message}`); | |
} | |
return { type: 'ok', value: maybeUsername.value }; | |
} | |
type IncrementAgeOpts = { userIdDoingRequest: string, noopIfUsernotFound?: boolean } | |
type IncrementAgeExits = { type: 'ok' } | { type: 'unauthorized' }; | |
function incrementAge(userId: string, { userIdDoingRequest, noopIfUsernotFound = false }: IncrementAgeOpts): IncrementAgeExits { | |
const maybeAge = userManager.getProperty(userId, 'age', { userIdDoingRequest }); | |
if (maybeAge.type === 'notFound') { | |
if (noopIfUsernotFound) return { type: 'ok' }; | |
throw new Error('User not found'); | |
} else if (maybeAge.type === 'unauthorized') { | |
return maybeAge; | |
} else if (maybeAge.type === 'missingProp') { | |
// Escalating this exception into an error, because, we know that the way we're calling getProperty(), this should never happen, | |
// so if it does, we throw an error. | |
throw new Error(`Missing Prop: ${maybeAge.message}`); | |
} | |
assert(maybeAge.value !== undefined, 'Can only increment the age of a user who has an age property'); | |
const maybeSuccess = userManager.setProperty(userId, 'age', maybeAge.value + 1, { userIdDoingRequest }); | |
if (maybeSuccess.type === 'unauthorized') { | |
throw new Error( | |
`User with id ${userId} was not authorized to increment another's age. ` + | |
'Please make sure they have the appropriate permissions before calling this function.' | |
); | |
} | |
assert(maybeSuccess.type === 'ok'); | |
return { type: 'ok' }; | |
} | |
type GivePermissionToUsersOpts = { userIdDoingRequest: string }; | |
type givePermissionToUsers = { type: 'ok' } | { type: 'unauthorized' } | { type: 'notFound', message: string }; | |
function givePermissionToUsers(permission: Permission, userIds: string[], { userIdDoingRequest }: GivePermissionToUsersOpts): givePermissionToUsers { | |
const allowed = ( | |
userManager.doesUserHasPermission(userIdDoingRequest, 'VIEW') && | |
userManager.doesUserHasPermission(userIdDoingRequest, 'UPDATE') | |
); | |
if (!allowed) { | |
return { type: 'unauthorized' }; | |
} | |
const users: User[] = []; | |
for (const userId of userIds) { | |
const maybeUser = userManager.getUser(userId, { userIdDoingRequest }) | |
if (maybeUser.type === 'notFound') { | |
return { type: 'notFound', message: `User with ID ${userId} not found.` }; | |
} else if (maybeUser.type === 'unauthorized') { | |
throw new Error(`Unexpected failure when fetching users. Received unauthorized response.`); | |
} | |
// Note that if a new exception return type is ever added to the getUser() response, TypeScript will flag this piece | |
// of code, and notify us that it needs to be updated to handle it. You can try it to see how it works. | |
users.push(maybeUser.value); | |
} | |
// Only start doing modifications after we've checked that all users exist. | |
for (const user of users) { | |
if (!user.permissions.includes(permission)) { | |
const newPermissions = [...user.permissions, permission]; | |
const setPropertyResponse = userManager.setProperty(user.id, 'permissions', newPermissions, { userIdDoingRequest }); | |
assert(setPropertyResponse.type === 'ok'); | |
} | |
} | |
return { type: 'ok' }; | |
} | |
function main() { | |
const maybeUsername = getUsername('2', { userIdDoingRequest: '3', fallback: null }); | |
if (maybeUsername.type !== 'unauthorized') throw new Error('UNREACHABLE'); | |
console.log('User with id 3 does not have permissions to view other users'); | |
givePermissionToUsers('VIEW', ['3'], { userIdDoingRequest: '1' }); | |
const usernameResult = getUsername('2', { userIdDoingRequest: '3', fallback: null }); | |
assert(usernameResult.type === 'ok'); | |
console.log('Username of id 2 is: ' + usernameResult.value); | |
const incrementAgeResult1 = incrementAge('5', { userIdDoingRequest: '1', noopIfUsernotFound: true }); | |
assert(incrementAgeResult1.type === 'ok'); | |
const ageResult1 = userManager.getProperty('3', 'age', { userIdDoingRequest: '1' }); | |
if (ageResult1.type !== 'ok') throw new Error(`Unexpected exit type: ${ageResult1.type}`); | |
console.log('age before increment: ' + ageResult1.value); | |
const incrementAgeResult2 = incrementAge('3', { userIdDoingRequest: '1' }); | |
assert(incrementAgeResult2.type === 'ok'); | |
const ageResult2 = userManager.getProperty('3', 'age', { userIdDoingRequest: '1' }); | |
assert(ageResult2.type === 'ok'); | |
console.log('age after increment: ' + ageResult2.value); | |
/* Expected output | |
User with id 3 does not have permissions to view other users | |
Username of id 2 is: bob | |
age before increment: 9 | |
age after increment: 10 | |
*/ | |
} | |
main(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { users } from './exampleData'; | |
export type Permission = 'VIEW' | 'UPDATE'; | |
export interface User { | |
id: string | |
username: string | |
permissions: Permission[] | |
age?: number | |
} | |
export const PERMISSIONS = { | |
view: 'VIEW', | |
update: 'UPDATE', | |
}; | |
type GetUserOpts = { userIdDoingRequest: string }; | |
type GetUserExits = { type: 'ok', value: User } | { type: 'notFound' } | { type: 'unauthorized' }; | |
export function getUser(userId: string, { userIdDoingRequest }: GetUserOpts): GetUserExits { | |
if (!doesUserHasPermission(userIdDoingRequest, 'VIEW')) { | |
return { type: 'unauthorized' }; | |
} | |
const user = users.get(userId); | |
if (!user) { | |
return { type: 'notFound' }; | |
} | |
return { type: 'ok', value: user }; | |
} | |
type GetPropertyOpts = { userIdDoingRequest: string }; | |
type GetPropertyExits<Key extends keyof User> = { type: 'ok', value: User[Key] } | |
| { type: 'notFound' } | |
| { type: 'unauthorized' } | |
| { type: 'missingProp', message: string }; | |
export function getProperty<Key extends keyof User>(userId: string, propName: Key, { userIdDoingRequest }: GetPropertyOpts): GetPropertyExits<Key> { | |
const maybeUser = getUser(userId, { userIdDoingRequest }); | |
if (maybeUser.type === 'notFound' || maybeUser.type === 'unauthorized') { | |
return maybeUser; | |
} | |
const user = maybeUser.value; | |
if (Object.hasOwn(user, propName)) { | |
return { type: 'ok', value: user[propName] }; | |
} else { | |
return { | |
type: 'missingProp', | |
message: `The property "${propName}" does not exist on the provided user.`, | |
}; | |
} | |
} | |
type SetPropertyOpts = { userIdDoingRequest: string }; | |
type SetPropertyExits = { type: 'ok' } | |
| { type: 'notFound' } | |
| { type: 'unauthorized' } | |
| { type: 'missingProp' }; | |
export function setProperty<Key extends keyof User>(userId: string, propName: Key, value: User[Key], { userIdDoingRequest }: SetPropertyOpts): SetPropertyExits { | |
if (!doesUserHasPermission(userIdDoingRequest, 'UPDATE')) { | |
return { type: 'unauthorized' }; | |
} | |
const maybeUser = getUser(userId, { userIdDoingRequest }); | |
if (maybeUser.type === 'notFound' || maybeUser.type === 'unauthorized') { | |
return maybeUser; | |
} | |
const user = maybeUser.value; | |
if (!Object.prototype.hasOwnProperty.call(user, propName)) { | |
return { type: 'missingProp' }; | |
} | |
user[propName] = value; | |
return { type: 'ok' }; | |
} | |
export function doesUserHasPermission(userId: string, permission: Permission): boolean { | |
const user = users.get(userId); | |
if (!user) { | |
throw new Error('The ID of the user doing the request was not found.'); | |
} | |
return user.permissions.includes(permission); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment