@moojo/misc-constructs
v0.7.0
Published
Compact TypeScript utilities that improve type-safety for error handling and exhaustiveness checking
Readme
@moojo/misc-constructs
TypeScript utilities for type-safe error handling and exhaustiveness checking.
Installation
npm install @moojo/misc-constructsAPI
Exhaustive Handling of String Unions
shouldNeverHappen
Compile-time exhaustiveness checking for union types. Place at the end of conditional chains to ensure all cases are handled.
import { shouldNeverHappen } from '@moojo/misc-constructs'
type Status = 'pending' | 'success' | 'failure'
function handleStatus(status: Status) {
if (status === 'pending') return 'Waiting...'
if (status === 'success') return 'Done!'
if (status === 'failure') return 'Failed'
shouldNeverHappen(status) // Compile error if a case is missing
}When you add a new variant to a union type, the compiler won't warn you about all the switch statements and if-chains that need updating. Code silently falls through, and bugs appear at runtime. By placing shouldNeverHappen(x) at the end of your conditional logic, TypeScript will produce a compile error whenever a case is unhandled. This turns a runtime bug into a build-time error, making union type refactoring safe.
switchOn
Type-safe pattern matching. Compiler enforces that all cases are present and no extra cases exist.
import { switchOn } from '@moojo/misc-constructs'
type Op = '+' | '-' | '*'
function calculate(op: Op, a: number, b: number): number {
return switchOn(op, {
'+': () => a + b,
'-': () => a - b,
'*': () => a * b,
})
}TypeScript's switch statement doesn't enforce exhaustiveness by default, and it's a statement rather than an expression, so you can't use it inline. switchOn solves both problems: it's an expression that returns a value, and the compiler will reject code that has missing or extraneous cases. This makes it impossible to forget a case when the union type changes, and impossible to leave dead code behind when a case is removed.
Throwing Errors
failMe
Throws an error when evaluated. Useful as the right-hand side of ?? or || when a fallback value cannot be provided.
import { failMe } from '@moojo/misc-constructs'
// Environment variables
const port = process.env['PORT'] || failMe('PORT env variable is required')
// Nullish coalescing
const config = loadConfig() ?? failMe('config must be defined')
// After .find() - when the item must exist
const user = users.find(u => u.id === id) ?? failMe(`user not found: ${id}`)
// After .match() - when the pattern must match
const [_, commit] = input.match(/^@([0-9a-f]+)$/) ?? failMe(`bad commit hash: ${input}`)
// Ternary chains - when all branches must be covered
const handler = isAdmin ? adminHandler : isGuest ? guestHandler : failMe('unknown user type')Compare with the traditional approach using throw:
// With failMe - concise, expression-based
const port = process.env['PORT'] || failMe('PORT is required')
// Without failMe - verbose, statement-based
const port = process.env['PORT']
if (!port) {
throw new Error('PORT is required')
}
// With failMe - stays in expression context
const user = users.find(u => u.id === id) ?? failMe(`not found: ${id}`)
// Without failMe - breaks the flow
const user = users.find(u => u.id === id)
if (!user) {
throw new Error(`not found: ${id}`)
}failMe is a natural fit for teams that follow the fail-early-and-loud philosophy. When a precondition is violated, you want the code to fail immediately with a clear message, not silently continue with undefined or a default value that masks the real problem. failMe makes these assertions visible and keeps them inline with the code that depends on them.
Handling Errors
errorLike
Safely extracts error properties from caught values. Avoids unsafe type assertions on unknown.
import { errorLike } from '@moojo/misc-constructs'
try {
await riskyOperation()
} catch (e) {
// Without fallback - message may be undefined
const { message, stack } = errorLike(e)
// With fallback - message is guaranteed to be a string
const err = errorLike(e, 'Operation failed')
console.error(err.message) // string, never undefined
}In JavaScript, anything can be thrown—not just Error instances, but strings, numbers, objects, or even undefined. TypeScript correctly types caught values as unknown, but this makes accessing .message or .stack awkward. The common workaround is (e as Error).message, but this is unsafe: if someone threw a string or a plain object, your code will misbehave. errorLike inspects the caught value at runtime and extracts properties only if they exist and are strings, giving you a consistent interface without lying to the type system.
catchMe / catchMeAsync
Captures execution results or errors in a structured format. Primarily for testing scenarios where you need to examine error properties.
import { catchMe, catchMeAsync } from '@moojo/misc-constructs'
// Synchronous
const result = catchMe(() => JSON.parse('{invalid}'))
if (!result.success) {
console.log(result.error) // The thrown value
console.log(result.postmortem) // All enumerable properties of the error
}
// Asynchronous
const asyncResult = await catchMeAsync(async () => {
return await fetchData()
})
if (asyncResult.success) {
console.log(asyncResult.data)
}In tests:
test('throws on invalid input', () => {
expect(catchMe(() => parseConfig(''))).toMatchObject({
success: false,
postmortem: {
message: expect.stringContaining('empty input'),
},
})
})Jest's toThrow() matcher only lets you check the error message or type. But errors often carry additional properties—error codes, HTTP status codes, metadata—that you want to verify. catchMe captures the thrown value and extracts all its enumerable properties into a postmortem object, which you can then assert against with toMatchObject. This gives you fine-grained control over error assertions without resorting to try-catch blocks in every test.
License
MIT
