Result
Migration Guide
From try-catch to Result
Step-by-step guide to refactoring exception-based code to Result-based code.
Why Migrate?
Before (try-catch):
- Errors are invisible in function signatures
- Easy to forget error handling
- Type information lost in catch blocks
- Hard to compose operations
- Exceptions break control flow
After (Result):
- Errors are explicit in types
- Compiler forces you to handle errors
- Full type safety
- Composable with
pipe - Errors are values
Step 1: Identify throw-happy Functions
Before: Hidden Exceptions
// ❌ No indication this can fail
function parseJSON(str: string) {
return JSON.parse(str) // Throws SyntaxError
}
function getUser(id: string) {
const user = users.find(u => u.id === id)
if (!user) throw new Error('User not found') // Hidden!
return user
}After: Explicit Results
// ✅ Type signature shows it can fail
function parseJSON<T>(str: string): Result<T, SyntaxError> {
return tryCatch(
() => JSON.parse(str) as T,
(error) => error as SyntaxError
)
}
function getUser(id: string): Result<User, string> {
return fromNullable(
users.find(u => u.id === id),
'User not found'
)
}Step 2: Refactor Error Handling
Pattern 1: Simple try-catch
Before:
function loadConfig() {
try {
const content = fs.readFileSync('config.json', 'utf8')
return JSON.parse(content)
} catch (error) {
console.error('Failed to load config:', error)
return defaultConfig
}
}After:
function loadConfig(): Config {
return pipe(
tryCatch(() => fs.readFileSync('config.json', 'utf8')),
flatMap(content => tryCatch(() => JSON.parse(content))),
tapErr(error => console.error('Failed to load config:', error)),
unwrapOr(defaultConfig)
)
}Pattern 2: Nested try-catch
Before:
async function createUser(email: string, password: string) {
try {
const hash = await bcrypt.hash(password, 10)
try {
const user = await db.user.create({ email, passwordHash: hash })
try {
await sendWelcomeEmail(user.email)
} catch (emailErr) {
// Email failed but user created - what now?
console.error('Email failed:', emailErr)
}
return user
} catch (dbErr) {
throw new Error(`Database error: ${dbErr.message}`)
}
} catch (hashErr) {
throw new Error(`Hashing failed: ${hashErr.message}`)
}
}After:
async function createUser(
email: string,
password: string
): Promise<Result<User, string>> {
const hash = await tryCatchAsync(
() => bcrypt.hash(password, 10),
(error) => `Hashing failed: ${error}`
)
if (isErr(hash)) return hash
return pipe(
await tryCatchAsync(
() => db.user.create({ email, passwordHash: hash.value }),
(error) => `Database error: ${error}`
),
tap(user => {
// Email is optional - don't fail the whole operation
sendWelcomeEmail(user.email).catch(console.error)
})
)
}flatMap can't chain the two steps here: its callback must return a Result, and these steps return a Promise. await each async step and return early on Err.
Pattern 3: Error Re-throwing
Before:
async function fetchUserData(id: string) {
try {
const user = await fetchUser(id)
if (!user) {
throw new Error('User not found')
}
const posts = await fetchPosts(user.id)
return { user, posts }
} catch (error) {
logger.error('fetchUserData failed', error)
throw error // Re-throw
}
}After:
async function fetchUserData(
id: string
): Promise<Result<{ user: User; posts: Post[] }, string>> {
const user = await fetchUser(id)
if (isErr(user)) {
logger.error('fetchUserData failed', user.error)
return user
}
return pipe(
await fetchPosts(user.value.id),
map(posts => ({ user: user.value, posts })),
tapErr(error => logger.error('fetchUserData failed', error))
)
}Step 3: Update Callers
Before: try-catch at Call Site
import type { Request, Response } from 'express'
async function handler(req: Request, res: Response) {
try {
const user = await createUser(req.body.email, req.body.password)
res.json({ success: true, user })
} catch (error) {
res.status(500).json({ success: false, error: error.message })
}
}After: match at Call Site
import type { Request, Response } from 'express'
async function handler(req: Request, res: Response) {
const result = await createUser(req.body.email, req.body.password)
match(result, {
onOk: (user) => {
res.json({ success: true, user })
},
onErr: (error) => {
res.status(500).json({ success: false, error })
}
})
}Step 4: Compose Operations
Before: Sequential try-catch
async function processOrder(orderId: string) {
try {
const order = await fetchOrder(orderId)
const user = await fetchUser(order.userId)
const payment = await processPayment(order.total, user.paymentMethod)
await sendConfirmation(user.email, order.id)
return { success: true }
} catch (error) {
return { success: false, error: error.message }
}
}After: Errors as Values
Every step is async, so await each one and return as soon as one fails.
async function processOrder(
orderId: string
): Promise<Result<{ success: true }, string>> {
const order = await fetchOrder(orderId)
if (isErr(order)) return err(order.error.message)
const user = await fetchUser(order.value.userId)
if (isErr(user)) return err(user.error.message)
const payment = await processPayment(order.value.total, user.value.paymentMethod)
if (isErr(payment)) return err(payment.error.message)
await sendConfirmation(user.value.email, order.value.id)
return ok({ success: true })
}When the steps are synchronous, the same logic is a pipe of flatMap calls.
Step 5: Handle Null/Undefined
Before: Null Checks Everywhere
function getUserEmail(userId: string): string | null {
const user = users.find(u => u.id === userId)
if (!user) return null
if (!user.email) return null
return user.email
}
// Caller
const email = getUserEmail(id)
if (email === null) {
// handle missing
}After: fromNullable
function getUserEmail(userId: string): Result<string, string> {
return pipe(
fromNullable(
users.find(u => u.id === userId),
'User not found'
),
flatMap(user =>
fromNullable(user.email, 'User has no email')
)
)
}
// Caller
const email = getUserEmail(id)
match(email, {
onOk: (e) => sendEmail(e),
onErr: (error) => console.log(error)
})Step 6: Validation Migration
Before: Throwing Validators
function validateUser(data: unknown): User {
if (!data || typeof data !== 'object') {
throw new Error('Invalid data')
}
if (!('email' in data) || typeof data.email !== 'string') {
throw new Error('Email required')
}
if (!data.email.includes('@')) {
throw new Error('Invalid email')
}
// ... more validation
return data as User
}
// Usage
try {
const user = validateUser(input)
await createUser(user)
} catch (error) {
showError(error.message)
}After: Result-based Validators
function validateUser(data: unknown): Result<User, string> {
if (!data || typeof data !== 'object') {
return err('Invalid data')
}
return pipe(
fromNullable((data as any).email, 'Email required'),
flatMap(email =>
email.includes('@') ? ok(email) : err('Invalid email')
),
// Chain more validations
flatMap(email => map(validateAge((data as any).age), () => email)),
map(email => ({ email, ...data } as User))
)
}
// Usage
const validated = validateUser(input)
const created = isOk(validated) ? await createUser(validated.value) : validated
match(created, {
onOk: () => showSuccess(),
onErr: showError
})Step 7: Express/API Route Migration
Before: try-catch in Routes
app.post('/api/users', async (req, res) => {
try {
const user = await db.user.create({ data: req.body })
res.status(201).json(user)
} catch (error) {
if (error.code === 'P2002') {
res.status(409).json({ error: 'Email already exists' })
} else {
res.status(500).json({ error: 'Internal server error' })
}
}
})After: Result-based Routes
app.post('/api/users', async (req, res) => {
const valid = validateUserInput(req.body)
const result = isErr(valid)
? valid
: await tryCatchAsync(
() => db.user.create({ data: valid.value }),
(error: any) => {
if (error.code === 'P2002') {
return { status: 409, message: 'Email already exists' }
}
return { status: 500, message: 'Internal server error' }
}
)
match(result, {
onOk: (user) => res.status(201).json(user),
onErr: (error) => res.status(error.status).json({ error: error.message })
})
})Migration Checklist
- Identify all functions that can throw
- Convert throwing functions to return
Result - Update function signatures with explicit error types
- Replace
try-catchwithtryCatch/tryCatchAsync - Replace null checks with
fromNullable - Chain operations with
flatMapinstead of nesting try-catch - Use
matchorunwrapOrat boundaries (API routes, UI) - Add error logging with
tapErr - Update tests to check Result values
- Update TypeScript types to remove
throwscomments
Common Pitfalls
❌ Pitfall 1: Still throwing inside Result
// BAD
const bad = tryCatch(() => {
if (condition) throw new Error('fail') // Defeats the purpose!
return value
})
// GOOD
const good = condition ? err('fail') : ok(value)❌ Pitfall 2: Using unwrap() everywhere
// BAD - defeats the purpose
const user = unwrap(fetchUser(id)) // Throws if Err!
// GOOD - handle the error
const user = unwrapOr(fetchUser(id), guestUser)
// OR
match(fetchUser(id), {
onOk: (user) => renderUser(user),
onErr: (error) => renderError(error)
})❌ Pitfall 3: Mixing Result and exceptions
// BAD - inconsistent error handling
function process(): Result<T, E> {
if (bad) throw new Error('fail') // Don't throw!
return ok(value)
}
// GOOD - all errors as Results
function process(): Result<T, E> {
return bad ? err('fail') : ok(value)
}Incremental Migration Strategy
- Start at the edges: API routes, form handlers
- Work inward: Services, repositories
- Convert utilities last: Pure functions, helpers
- Keep boundaries clean: Use
matchorunwrapOrat system boundaries - Update tests incrementally: One module at a time
Next Steps
- API Reference: Quick function lookup
- Patterns: Complete recipes for common scenarios