Skip to content

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:

ts
// Hypothetical API that throws on failure (NOT how Mikro.js works)
const 
value
=
readSensor
(34)
console
.
log
(`Sensor: ${
value
}`)

This compiles without any warnings. TypeScript says readSensor returns number, so value is number. But at runtime, this can crash your program. If channel 34 doesn't exist, 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:

ts
try {
  const 
value
=
readSensor
(34)
console
.
log
(`Sensor: ${
value
}`)
} catch (
err
) {
// //
console
.
error
('Something went wrong:',
err
)
}

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 logging the raw value.

And this uncertainty is contagious. Any function that calls readSensor 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.

How Mikro.js handles errors ​

Every Mikro.js function that can fail returns a Result:

ts
import {
AnalogIn
} from 'mikro/gpio'
const
result
=
AnalogIn
(34).
andThen
((
sensor
) =>
sensor
.
read
())
// // if (
result
.
ok
) {
console
.
log
(`Sensor: ${
result
.
value
}`)
} else { switch (
result
.
error
.
name
) {
// // // case 'InvalidGpio':
console
.
error
('Cannot use GPIO 34 for the sensor:',
result
.
error
)
break case 'GpioInUse':
console
.
error
('Cannot read the sensor on GPIO 34:',
result
.
error
)
break default:
console
.
error
('Read failed:',
result
.
error
)
} }

The key differences:

  • The type signature is honest: sensor.read() returns Result<number, GpioError>, so you can see it might fail.
  • The error is typed: GpioError is a union of specific variants, and 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:

ts
import {
ok
,
err
} from 'mikro/result'
const
success
=
ok
(42)
// // const
failure
=
err
('oops')
// //

Early returns ​

The most common pattern is checking and returning early:

ts
async function 
readAndPublish
(
sensor
: AnalogIn) {
const
reading
=
sensor
.
read
()
if (!
reading
.
ok
) return
reading
const
published
= await
publish
(
reading
.
value
)
if (!
published
.
ok
) return
published
return
ok
()
}

Notice how TypeScript infers a return type equivalent to Result<void, GpioError | PublishError>, capturing every way the function can fail. Each early return propagates its own error type, and the caller sees exactly which errors are possible.

If publish were to throw instead, that would be a panic: an unexpected bug, not a recoverable error. See What about exceptions? below.

Transforming values ​

Use .map() to transform the success value without unwrapping:

ts
const 
voltage
=
sensor
.
readMillivolts
().
map
((
mv
) =>
mv
/ 1000)

Use .andThen() to chain operations that themselves return Results:

ts
const 
result
=
AnalogIn
(34).
andThen
((
sensor
) =>
sensor
.
readMillivolts
())

Converting to panics ​

When failure is unrecoverable and you want to crash explicitly, use .orPanic():

ts
const 
sensor
=
AnalogIn
(34).
orPanic
('ADC must work at this point')

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:

ts
const 
message
=
sensor
.
read
().
match
({
ok
: (
value
) => `Reading: ${
value
}`,
err
: (
error
) => `Failed: ${
error
.
name
}`,
})

Defining errors ​

Mikro.js modules define their errors as tagged unions, keyed on name. The recommended shape is a const factory object plus a ReturnType extraction:

ts
const 
SensorError
= {
NotConnected
: () => ({
name
: 'NotConnected'}) as
const
,
ReadFailed
: (
message
: string) => ({
name
: 'ReadFailed',
message
}) as
const
,
OutOfRange
: (
value
: number,
min
: number,
max
: number) =>
({
name
: 'OutOfRange',
value
,
min
,
max
}) as
const
,
} type
SensorError
=
ReturnType
<(typeof
SensorError
)[keyof typeof
SensorError
]>

This gives you:

  • Constructor functions: SensorError.ReadFailed("timeout") creates {name: "ReadFailed", message: "timeout"}
  • A union type: SensorError is {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 switch block, or via matchError

Errors are plain data, not class instances. Cheap to create and easy to inspect.

For variants with only one or two call sites, an inline err({name: 'X' as const, ...}) literal is fine; the factory pattern is just discoverability over the same shape.

Logging errors ​

Pass the error as its own argument to console.error or console.warn, after a short context string:

ts
const 
result
= await
wifi
.
connect
({
ssid
: 'net',
passphrase
: 'pw'})
if (!
result
.
ok
) {
console
.
error
('WiFi connect failed:',
result
.
error
)
}

The console prints the whole value. A Result error prints as an object with every field, and a cause field chains below it:

WiFi connect failed: { name: 'ConnectFailed', message: 'auth failed' }
  [cause]: { name: 'Dhcp', message: 'no lease' }

An Error instance prints its name and message, any extra own fields, the stack, and the cause chain, nested under it:

update failed: Error: download failed { code: 7 }
    at update (main.js:12:3)
  [cause]: { name: 'Timeout', message: 'no response after 5000ms' }

Do not format the error into the message. Each of these keeps one piece and throws away the rest:

ts
// Keeps the name, loses message, fields, stack and cause
console.error('WiFi connect failed: %s', result.error.name)
console.error(`WiFi connect failed: ${result.error.message}`)
console.error('WiFi connect failed: ' + String(result.error))

A failure with no error value is logged the same way, with the value that describes it:

ts
console.error('checkin returned status:', response.status)

Passing errors along ​

The same rule applies when an error moves instead of being logged. Keep the original value:

  • Returning from a function: propagate the typed error as-is (if (!r.ok) return r), or add context and keep the original as cause: err(new Error('download failed', {cause: r.error})). Never flatten to {message: r.error.name}.
  • Panicking: result.orPanic('WiFi required') keeps the error as the panic's cause, and the crash report prints it. Do not build the message yourself with panic(`failed: ${result.error.name}`).
  • Converting a caught exception to a Result: carry the thrown value as cause, not e.message. See Creating drivers for the native-boundary pattern.

What about exceptions? ​

Exceptions still exist in Mikro.js, but they're treated as panics: unrecoverable bugs, not expected error conditions.

The distinction:

Result errorsExceptions (panics)
WhenExpected failures (bad pin, network timeout)Bugs (null dereference, out of memory)
Typed?Yes, visible in function signatureNo, unknown
Handle?Yes, check result.okNo, let it crash and read the stack trace
Stack trace?No, you know what failed from the typeYes, you need it because the failure is unexpected

If you see an exception in Mikro.js, it means something is 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. Catch them at the call site and return a Result: see no-try-catch for the pattern and the lint disable it needs.

When you need to intentionally crash (for example on missing required configuration), use env.require from mikro/env or panic from mikro/sys:

ts
import {
env
} from 'mikro/env'
// env.require() panics with a clear message if the variable is not set const
ssid
=
env
.
require
('WIFI_SSID')

The runtime restarts the device after an uncaught exception, so a deployed app recovers on its own. Use onPanic to control this: delay sets how long the device waits before acting (use a longer delay during development to give yourself time to recover the device, a shorter one in production), and mode chooses whether it restarts right away or deep-sleeps to save power first:

ts
import {
defineConfig
} from 'mikro'
export default
defineConfig
({
onPanic
: {
mode
: 'restart',
delay
: 500},
})

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.

Battery-powered devices

Auto-restart only self-heals if the panic was transient. If the same panic recurs every boot (a coding bug, a permanently missing sensor, bad NVS state), the device crash-loops and a battery-powered one will drain itself doing so. Save .orPanic() for boot-time invariants you can fix by reflashing; for anything that might fail in the field, handle the Result.

Lint rules ​

Mikro.js includes an ESLint plugin that enforces these conventions. For example, the no-unhandled-result rule warns you when a Result is ignored:

ts
sensor.read()
// error: Result must be handled (@mikrojs/no-unhandled-result)

Other rules flag throw, try/catch, Promise.reject(), and .catch() to keep error handling consistent. See ESLint Rules for the full list.

Quick reference ​

ts
import {ok, err, matchError, type Result} from 'mikro/result'

// Check a result
if (result.ok) {
  result.value // the success value
} else {
  result.error // the typed error
}

// Early return on error
const result = sensor.read()
if (!result.ok) return result

// Log an error: context string, then the error itself (never %s / .name / .message)
if (!result.ok) console.error('read failed:', result.error)

// Add context while returning: keep the original as cause
if (!result.ok) return err(new Error('sensor read failed', {cause: result.error}))

// 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 = {
  NotFound: (id: string) => ({name: 'NotFound', id}) as const,
  Timeout: (ms: number) => ({name: 'Timeout', ms}) as const,
}
type MyError = ReturnType<(typeof MyError)[keyof typeof MyError]>

// Use in function signatures
function myFunction(): Result<string, MyError> {
  if (somethingWrong) return err(MyError.Timeout(5000))
  return ok('done')
}

Mikro.js is built with AI assistance, code and docs alike. Read the AI disclosure.