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:

typescript
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:

typescript
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:

typescript
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. analogRead returns Result<number, PinError>; you can see it might fail.
  • The error is typed. PinError is 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:

typescript
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:

typescript
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:

typescript
const voltage = analogReadMillivolts(34).map((mv) => mv / 1000) // Result<number, PinError> — converts mV to V

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

typescript
const result = pinMode(4, 'OUTPUT').andThen(() => digitalWrite(4, 1))

Converting to panics

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

typescript
const value = analogRead(34).orPanic('ADC must work at this point')
// value is number — or the program crashes with the given message

This 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:

typescript
const message = analogRead(34).match({
  ok: (value) => `Reading: ${value}`,
  err: (error) => `Failed: ${error.name}`,
})

Defining errors

Mikro.js modules define their errors using defineError:

typescript
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: 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

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 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, 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 fundamentally broken, not that a sensor read failed.

When you need to intentionally crash (e.g. missing required configuration), use panic from mikrojs/sys:

typescript
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:

typescript
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:

typescript
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

typescript
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')
}