Error Handling in Mikro.js
Mikro.js uses typed results instead of exceptions. If you're coming from a try/catch background, this guide explains why and how to work with them.
The problem with try/catch
Consider reading a sensor value:
import {analogRead} from 'mikrojs/pin'
const value = analogRead(34)
console.log(`Sensor: ${value}`)This compiles without any warnings. TypeScript says analogRead returns number, so value is number. But at runtime, this can crash. If pin 34 isn't a valid ADC pin, the function throws. Nothing in the type signature tells you this can happen. You have to know to add error handling.
You might wrap it in try/catch:
try {
const value = analogRead(34)
console.log(`Sensor: ${value}`)
} catch (err) {
// What is `err`? TypeScript says `unknown`.
console.error('Something went wrong:', err)
}But now you have a new problem: err is unknown. Is it a string? An Error? Does it have a .message? A .code? You end up writing if (err instanceof Error) checks, guessing at property names, or just logging and hoping for the best.
And this uncertainty is contagious. Any function that calls analogRead might also throw, but there's no way to know from its signature. You either wrap everything in try/catch defensively, or you don't and hope for the best. Neither option is good.
On a microcontroller with 300KB of heap, where you're debugging over a serial monitor, "something went wrong" isn't good enough.
How Mikro.js handles errors
Every Mikro.js function that can fail returns a Result:
import {analogRead, PinError} from 'mikrojs/pin'
const result = analogRead(34)
if (result.ok) {
console.log(`Sensor: ${result.value}`)
} else {
// result.error is typed: you know exactly what went wrong
switch (result.error.name) {
case 'InvalidAdcPin':
console.error('Pin 34 is not a valid ADC pin')
break
case 'AdcInitFailed':
console.error('ADC hardware failed:', result.error.message)
break
}
}The key differences:
- The type signature is honest.
analogReadreturnsResult<number, PinError>; you can see it might fail. - The error is typed.
PinErroris a union of specific variants. TypeScript tells you exactly which errors are possible and what data each one carries. - No try/catch needed. Errors are values you check, not exceptions you catch.
The Result type
A Result<T, E> is either an Ok holding a value of type T, or an Err holding an error of type E:
import {ok, err} from 'mikrojs/result'
// Creating results
const success = ok(42) // Result where ok=true, value=42
const failure = err('oops') // Result where ok=false, error='oops'
// Checking results
if (success.ok) {
success.value // 42 — TypeScript knows this exists
}
if (failure.ok === false) {
failure.error // 'oops' — TypeScript knows this exists
}Early returns
The most common pattern is checking and returning early:
async function readAndPublish(pin: number): Promise<Result<void, PinError>> {
const reading = analogRead(pin)
if (!reading.ok) return reading // propagate the error
await publish(reading.value)
return ok(undefined)
}This is the equivalent of Rust's ? operator, just spelled out. The error type propagates automatically; TypeScript infers that readAndPublish can return any PinError variant.
Transforming values
Use .map() to transform the success value without unwrapping:
const voltage = analogReadMillivolts(34).map((mv) => mv / 1000) // Result<number, PinError> — converts mV to VUse .andThen() to chain operations that themselves return Results:
const result = pinMode(4, 'OUTPUT').andThen(() => digitalWrite(4, 1))Converting to panics
When failure is unrecoverable and you want to crash explicitly, use .orPanic():
const value = analogRead(34).orPanic('ADC must work at this point')
// value is number — or the program crashes with the given messageThis is the equivalent of Rust's .expect(). Use it sparingly, only when you've decided that failure at this point means the program cannot continue.
Exhaustive matching
Use .match() to handle both cases:
const message = analogRead(34).match({
ok: (value) => `Reading: ${value}`,
err: (error) => `Failed: ${error.name}`,
})Defining errors
Mikro.js modules define their errors using defineError:
import {defineError, type ErrorOf} from 'mikrojs/result'
const SensorError = defineError('SensorError', {
NotConnected: () => ({}),
ReadFailed: (message: string) => ({message}),
OutOfRange: (value: number, min: number, max: number) => ({value, min, max}),
})
type SensorError = ErrorOf<typeof SensorError>This gives you:
- Constructor functions:
SensorError.ReadFailed("timeout")creates{name: "ReadFailed", message: "timeout"} - A union type:
SensorErroris{name: "NotConnected"} | {name: "ReadFailed", message: string} | {name: "OutOfRange", value: number, min: number, max: number} - Exhaustive switching: TypeScript enforces that you handle every variant in a
switchblock
Errors are plain objects; no class hierarchies, no prototypes. They're cheap to create and easy to log over serial.
What about exceptions?
Exceptions still exist in Mikro.js, but they're treated as panics: unrecoverable bugs, not expected error conditions.
The distinction:
| Result errors | Exceptions (panics) | |
|---|---|---|
| When | Expected failures (bad pin, network timeout) | Bugs (null dereference, out of memory) |
| Typed? | Yes—visible in function signature | No—unknown |
| Handle? | Yes—check result.ok | No—let it crash, read the stack trace |
| Stack trace? | No—you know what failed from the type | Yes—you need it because the failure is unexpected |
If you see an exception in Mikro.js, it means something is fundamentally broken, not that a sensor read failed.
Note that some standard JavaScript functions can still throw, such as JSON.parse() with invalid input, atob() with a malformed string, or decodeURIComponent() with invalid sequences. These are not Mikro.js APIs, so they follow standard JavaScript behavior. Use try/catch around these if the input is untrusted.
When you need to intentionally crash (e.g. missing required configuration), use panic from mikrojs/sys:
import {panic} from 'mikrojs/sys'
const {WIFI_SSID: ssid} = import.meta.env
if (!ssid) panic('WIFI_SSID env var is required')For production deployments, you can set restartOnUncaughtException: true in your mikro.config.ts to automatically restart the device when an unhandled exception occurs:
export default {
restartOnUncaughtException: true,
}This is the right response to a panic: restart and try again. Result errors, on the other hand, are handled in your code and never trigger a restart.
Lint rule: unhandled Result warnings
Mikro.js includes an eslint rule that warns you when a Result is ignored:
pinMode(4, 'OUTPUT')
// error: Result must be handled (@mikrojs/no-unhandled-result)The rule is enabled by default in new projects via @mikrojs/eslint-plugin. It detects any call expression whose return type is Result<T, E> or Promise<Result<T, E>> that isn't assigned to a variable or used in an expression.
Quick reference
import {ok, err, defineError, type Result, type ErrorOf} from 'mikrojs/result'
// Check a result
if (result.ok) {
result.value // the success value
} else {
result.error // the typed error
}
// Early return on error
const result = analogRead(pin)
if (!result.ok) return result
// Transform success
result.map(value => value * 2)
// Chain fallible operations
result.andThen(value => anotherOperation(value))
// Handle both cases
result.match({
ok: (value) => ...,
err: (error) => ...,
})
// Get value or crash
const value = result.orPanic('must succeed here')
// Switch on error variant
switch (result.error.name) {
case 'InvalidAdcPin': ...
case 'AdcInitFailed': ...
}
// Define custom errors
const MyError = defineError('MyError', {
NotFound: (id: string) => ({id}),
Timeout: (ms: number) => ({ms}),
})
type MyError = ErrorOf<typeof MyError>
// Use in function signatures
function myFunction(): Result<string, MyError> {
if (somethingWrong) return err(MyError.Timeout(5000))
return ok('done')
}