#Effects & IO

You have been doing IO since chapter 1: a println in main prints. This chapter explains what the compiler was tracking while you did it, and how to read and write the effect row that appears in a signature.

#IO is a sequence of statements

To do several things in order, write them one per line, indented under the definition. There is no keyword to open such a block and no wrapper type around the result.

banner : String -> <IO> Unit
banner title =
  println "=="
  println title
  println "=="

main =
  let name = "August"
  banner name
  if name == "August" then println "month closed"
==
August
==
month closed

A block's value is the value of its last statement. let, if, nested blocks, and calls to other functions that do IO all work inside one. The only rule is that a statement whose value is not Unit cannot stand on its own; the compiler will not let a result be thrown away silently.

#The effect row

If IO is not marked by a wrapper type, what stops any function from printing? The signature does. A function type can carry an effect row, a list of labels in angle brackets in front of the result type:

double : Int -> Int
double n = n * 2

shout : String -> <IO> Unit
shout s = println s

main =
  println (double 21)
  shout "loud"
42
loud

shout says <IO>, so it may print. double has no row, which means it performs no effects, and the compiler holds it to that. Add a println to its body and checking fails:

error: probe.mdk:3:10: Effectful value used where <> is allowed, but it performs <Stdout>
  |
3 |   println "doubling"
  |           ^

So a signature tells you whether a function can touch the outside world, and the answer is checked. You do not have to read the body, and you do not have to trust a naming convention.

Effect rows are inferred like everything else, so the compiler would have worked out <Stdout> for shout on its own. Write the row on top-level definitions anyway, for the same reason you write the rest of the signature.

#Labels name capabilities

Each label in a row names something in the host environment. The built-in labels are Stdout, Stderr, Stdin, Clock, Env, Exec, Rand, Net, FileRead, FileWrite, Signal, and FFI. A row can name them individually:

nap : Int -> <Clock> Unit
nap ms = sleepMs ms

configured : Unit -> <Env> String
configured () = match getEnv "MEDAKA_GUIDE_DEMO"
  Some v => v
  None => "unset"

main =
  nap 1
  println (configured ())
Not runnable in the playground: uses `getEnv`, which the browser playground has no host support for
unset

IO is the umbrella label. A row of <IO> permits any of the labels above except FFI, which has to be named on its own because it leaves the language. Write <IO> when you mean "this touches the world" and the specific labels when you want the signature to say which part of the world. <Clock, IO> is legal and means the same as <IO>, since IO already includes Clock.

tick : String -> <Clock, IO> Unit
tick msg =
  sleepMs 1
  println msg

main =
  tick "first"
  tick "second"
first
second

A function cannot claim a narrower row than the functions it calls. println is declared <Stdout> in the prelude, so a function that calls it may be annotated <Stdout> or the wider <IO>, but not <> or <Stderr>. Narrowing what a caller has to permit would hide an effect, and the compiler refuses it.

You can declare your own labels with effect, and rows can contain variables (<e>) and open tails (<IO | e>) so that a higher-order function can pass its argument's effects through. Those are out of scope for this guide. The effects topic in Advanced Topics picks up exactly here and goes all the way to labels that name which file or host a function may touch; the syntax reference has the spellings.

#What do is not

If you have used a language where IO lives in a do block, do not reach for do here. Medaka has do, and chapter 8 is about it, but it is for chaining Option, Result, and similar values, not for sequencing IO. Putting println statements in a do block is an error:

error: probe.mdk:3:12: this `do` block needs a Thenable value here (like `Option` or
`Result`), but got Unit. If Unit isn't itself monadic, use 'let' instead of '<-' to
bind it.
  |
3 |     println "step one"
  |             ^

A second error, Ambiguous instance for Display, follows it and points at the do keyword. It is a consequence of the first. Fixing the first clears both.

⚠️ <- only works inside do. It is the piece of do syntax that most often leaks into a plain block, because it is what other languages build IO blocks from. In a plain block it is an error that names the fix:

error: probe.mdk:5:16: `<-` bind is only valid inside a `do` block. For IO
sequencing use a bare indented block without `<-`

#Mutation is not an effect

Ref cells, from chapter 2, are the other thing people mean by "side effect", and Medaka does not track them. Writing to a cell carries no label. A function that allocates a Ref, mutates it, and reads it back is pure as far as the type system is concerned:

sumTo : Int -> Int
sumTo n =
  let total = Ref 0
  let step = i => total := !total + i
  let _ = map step [1..=n]
  !total

main = println (sumTo 10)
55

sumTo has no row and needs none. A Ref created inside a function and never handed out is invisible from outside: same input, same output. (The let _ = is there because map step … produces a List Unit, and a statement is not allowed to discard a non-Unit value silently. Binding it to _ says the discard is intended.) The row tracks the observable boundary, meaning the console, the filesystem, the clock, and the network, not every assignment.

⚠️ An empty row does not mean "no mutation". It means the function cannot reach the world. A Ref passed in as an argument can still be written to by a function with no row, and the caller sees the write. Read the parameter types, not only the row.

#The expense log, from a file

The ledger has been a literal list so far. Real ledgers live on disk. File access is <IO> like everything else, and the read returns a Result because it can fail.

import io.{readLines}

logPath : String
logPath = "expenses.log"

writeLog : <IO> Result String Unit
writeLog =
  writeFile logPath "2026-08-01,Cafe Fish,4.50\n2026-08-02,Landlord,1200.0\n"

countEntries : String -> <IO> Int
countEntries path = match readLines path
  Ok ls => length ls
  Err _ => 0

main = match writeLog
  Err e => println "could not write the log: \{e}"
  Ok () =>
    println "wrote \{logPath}"
    println (countEntries logPath)
Not runnable in the playground: uses `readLines`, which the browser playground has no host support for
wrote expenses.log
2

The failure is a value. readLines returns Result String (List String), so there is nothing to catch and nothing to forget, only a match you cannot skip. Notice also that the effect and the failure are tracked separately. <IO> in the row says the function touches the disk. Result in the return type says the touch might not work. Neither implies the other.

Coming from Haskell? readLines path is not an action you build and later run. It performs the read where it is written, and its type is the value it produced. The effect row replaces the IO wrapper, so there is no IO in a type constructor position anywhere.

Turning those lines into Expense values is a chain of steps that can each fail, which is the job of do. Chapter 8 picks up there.