Intermediate

I/O Operations in Standard ML

Learn console input, formatted output, file reading and writing, and I/O error handling in Standard ML using the TextIO structure with Docker-ready examples

Input and output are where Standard ML’s functional purity meets the messy real world. Unlike Haskell, which quarantines side effects inside the IO monad, SML takes a pragmatic approach: I/O operations are ordinary functions that perform side effects directly. Because SML uses strict (eager) evaluation, those effects happen in a predictable order — you can read a line, then write a file, then print a message, and they occur exactly in that sequence.

Almost all text I/O in SML lives in the Basis Library’s TextIO structure. It provides the standard streams (TextIO.stdIn, TextIO.stdOut, TextIO.stdErr), file operations (openIn, openOut, openAppend), and reading functions that return option values instead of failing — a very ML way to handle end-of-file. Where a C programmer checks for EOF and a Python programmer catches StopIteration, an SML programmer pattern matches on SOME line versus NONE.

In this tutorial you’ll build formatted console output with Real.fmt and StringCvt padding, read standard input with a recursive loop, write and read files, and handle missing-file errors with the IO.Io exception. Everything runs in the SML/NJ Docker image — no local installation needed.

Formatted Console Output

The print function only accepts strings, so producing formatted output is a matter of converting values to strings and combining them. The Basis Library provides Int.toString, Real.toString, Real.fmt for precision control, and StringCvt.padLeft/padRight for column alignment.

Create a file named formatted_output.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
(* Formatted output in Standard ML *)

val year = 1983
val pi = 3.14159

(* print takes a string, so convert other types first *)
val () = print ("Language: Standard ML\n")
val () = print ("First appeared: " ^ Int.toString year ^ "\n")

(* Reals: Real.toString for defaults, Real.fmt for precision control *)
val () = print ("Pi (default): " ^ Real.toString pi ^ "\n")
val () = print ("Pi (2 places): " ^ Real.fmt (StringCvt.FIX (SOME 2)) pi ^ "\n")

(* Column alignment with StringCvt.padRight and padLeft *)
fun row (label, value) =
  print (StringCvt.padRight #" " 12 label ^ " | " ^
         StringCvt.padLeft #" " 6 value ^ "\n")

val () = row ("item", "count")
val () = row ("bindings", Int.toString 4)
val () = row ("years", Int.toString (2026 - year))

Key points:

  • Real.fmt (StringCvt.FIX (SOME 2)) formats a real with a fixed number of decimal places. StringCvt.SCI gives scientific notation and StringCvt.GEN picks the shorter of the two.
  • StringCvt.padLeft #" " 6 s pads string s with spaces on the left to width 6 — right-aligning it. padRight left-aligns. The #" " is SML’s character literal syntax for a space.
  • There is no printf in the Basis Library; idiomatic SML composes small conversion functions with ^ instead of interpreting a format string at runtime.

Reading Standard Input

TextIO.inputLine TextIO.stdIn reads one line and returns SOME line (newline included) or NONE at end of input. Because the result is an option, the type system forces you to decide what happens at end-of-file — there is no way to forget the EOF case. As a functional language, SML expresses the read loop as a recursive function rather than a while loop.

Create a file named read_input.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
(* Reading lines from standard input *)

(* inputLine keeps the trailing newline; strip it if present *)
fun chomp line =
  if String.isSuffix "\n" line
  then String.substring (line, 0, String.size line - 1)
  else line

(* Recursive read loop: NONE signals end of input *)
fun processLines n =
  case TextIO.inputLine TextIO.stdIn of
      NONE => print ("Done. Read " ^ Int.toString (n - 1) ^ " line(s).\n")
    | SOME line =>
        ( print ("Line " ^ Int.toString n ^ ": " ^
                 String.map Char.toUpper (chomp line) ^ "\n")
        ; processLines (n + 1) )

val () = processLines 1

The case expression pattern matches on the option result: NONE ends the recursion with a summary, and SOME line uppercases the line with String.map Char.toUpper and recurses. This tail-recursive loop is the standard SML replacement for an imperative while (fgets(...)) pattern.

File Reading and Writing

File I/O follows the same open/operate/close shape found in most languages, but reading functions still return option values, and failures raise the IO.Io exception, which carries the file name and the failing operation in a record.

Create a file named file_io.sml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
(* Writing and reading files with TextIO *)

(* Write a file: openOut truncates or creates *)
val out = TextIO.openOut "languages.txt"
val () = TextIO.output (out, "Standard ML\n")
val () = TextIO.output (out, "OCaml\n")
val () = TextIO.output (out, "Haskell\n")
val () = TextIO.closeOut out

(* Read the whole file at once *)
val ins = TextIO.openIn "languages.txt"
val contents = TextIO.inputAll ins
val () = TextIO.closeIn ins
val () = print ("--- File contents ---\n" ^ contents)

(* Read line by line, numbering each line *)
fun numberLines (strm, n) =
  case TextIO.inputLine strm of
      NONE => ()
    | SOME line =>
        ( print (Int.toString n ^ ": " ^ line)
        ; numberLines (strm, n + 1) )

val ins2 = TextIO.openIn "languages.txt"
val () = print "--- Numbered ---\n"
val () = numberLines (ins2, 1)
val () = TextIO.closeIn ins2

(* Opening a missing file raises IO.Io *)
val () =
  (let val f = TextIO.openIn "missing.txt"
   in TextIO.closeIn f
   end)
  handle IO.Io {name, ...} =>
    print ("Could not open: " ^ name ^ "\n")

Notice the two reading styles: TextIO.inputAll slurps the entire remaining stream into one string (convenient for small files), while the recursive numberLines function processes one line at a time (better for large files or streaming transformations). The IO.Io exception is a record {name, function, cause} — pattern matching with {name, ...} extracts just the file name and ignores the rest.

Running with Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Pull the SML/NJ image
docker pull eldesh/smlnj:latest

# Run the formatted output example
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml formatted_output.sml

# Run the stdin example, piping two lines into the container
# (-i keeps stdin open so the pipe reaches the program)
printf "hello\nfunctional world\n" | docker run --rm -i -v $(pwd):/app -w /app eldesh/smlnj:latest sml read_input.sml

# Run the file I/O example (languages.txt is created in your current directory)
docker run --rm -v $(pwd):/app -w /app eldesh/smlnj:latest sml file_io.sml

SML/NJ prints its version banner and val bindings for each top-level declaration before and alongside your program’s output; the sections below show only what the programs themselves print.

Expected Output

Running formatted_output.sml:

Language: Standard ML
First appeared: 1983
Pi (default): 3.14159
Pi (2 places): 3.14
item         |  count
bindings     |      4
years        |     43

Running read_input.sml with the piped input shown above:

Line 1: HELLO
Line 2: FUNCTIONAL WORLD
Done. Read 2 line(s).

Running file_io.sml:

--- File contents ---
Standard ML
OCaml
Haskell
--- Numbered ---
1: Standard ML
2: OCaml
3: Haskell
Could not open: missing.txt

Streams, Flushing, and stderr

TextIO exposes three standard streams:

TextIO.stdIn  : TextIO.instream
TextIO.stdOut : TextIO.outstream
TextIO.stdErr : TextIO.outstream

print s is shorthand for writing to standard output, but writing to standard error requires the explicit form:

TextIO.output (TextIO.stdErr, "warning: something odd\n")

Output streams are buffered, so a prompt without a trailing newline may not appear before a read. Call TextIO.flushOut TextIO.stdOut after printing a prompt to force it out — a common pattern when writing interactive programs:

fun prompt msg =
  ( print msg
  ; TextIO.flushOut TextIO.stdOut
  ; TextIO.inputLine TextIO.stdIn )

Closing an output stream with TextIO.closeOut flushes it automatically, which is why file_io.sml needs no explicit flush.

Key Concepts

  • TextIO is the hub for text I/O — standard streams (stdIn, stdOut, stdErr), file operations (openIn, openOut, openAppend), and reads/writes all live in this one Basis Library structure.
  • End-of-file is an option, not an errorTextIO.inputLine returns SOME line or NONE, so the type system forces you to handle EOF explicitly with pattern matching.
  • Read loops are recursive functions — idiomatic SML replaces while-style input loops with tail-recursive functions that pattern match on each read result.
  • inputLine keeps the trailing newline — strip it yourself (e.g., with String.isSuffix and String.substring) when you need the bare line.
  • There is no printf — build formatted output by composing Int.toString, Real.fmt, and StringCvt.padLeft/padRight with the ^ concatenation operator.
  • I/O failures raise IO.Io — the exception carries a {name, function, cause} record, so a handle IO.Io {name, ...} clause can report exactly which file failed.
  • Choose inputAll or line-at-a-time deliberatelyinputAll is convenient for small files; recursive inputLine loops scale to large or streaming input.
  • Buffered output needs flushing before interactive reads — use TextIO.flushOut after a prompt, or rely on closeOut, which flushes on close.

Running Today

All examples can be run using Docker:

docker pull eldesh/smlnj:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining