Intermediate

I/O Operations in TypeScript

Learn console input and output, file reading and writing, and typed error handling in TypeScript with practical Docker-ready examples

Input and output are where a program meets the outside world: printing results to the terminal, reading what the user types, and saving or loading files. TypeScript itself defines no I/O — like everything at runtime, I/O comes from the JavaScript platform your code runs on. In this tutorial we use Node.js, whose node:fs and node:readline modules provide file and terminal I/O.

What TypeScript adds is a typed layer over these APIs. The compiler knows that readFile with an encoding returns a Promise<string>, that rl.question resolves to a string, and that a caught error is unknown until you narrow it. This is TypeScript’s multi-paradigm nature at work: the I/O calls are imperative, the data flowing through them is statically typed, and asynchronous operations compose functionally through async/await on promises.

In this tutorial you’ll learn the different ways to write to the console, how to read user input from stdin, how to read and write files asynchronously, and how to handle I/O errors with proper type narrowing.

Console Output Beyond console.log

Node.js gives you more control over output than a bare console.log: writing without newlines, printf-style formatting, and a separate stream for errors.

Create a file named console_output.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const language: string = "TypeScript";
const year: number = 2012;

// console.log writes to stdout and appends a newline
console.log("Standard output with console.log");

// Template literals interpolate typed values
console.log(`${language} first appeared in ${year}`);

// process.stdout.write does NOT append a newline
process.stdout.write("Building a line ");
process.stdout.write("piece by piece\n");

// printf-style format specifiers: %s for strings, %d for numbers
console.log("Name: %s, Year: %d", language, year);

// console.error writes to stderr, keeping errors separate from data
console.error("Warnings and errors go to stderr");

Keeping normal output on stdout and diagnostics on stderr matters when your program is used in a pipeline: npx tsx console_output.ts > data.txt captures the data while error messages still appear on screen.

Reading Input from stdin

Node.js reads terminal input through the node:readline module. The promise-based variant pairs naturally with async/await, and TypeScript knows each answer is a string — so converting to a number is an explicit, checked step.

Create a file named read_input.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import * as readline from "node:readline/promises";
import { stdin, stdout } from "node:process";

async function main(): Promise<void> {
    const rl = readline.createInterface({ input: stdin, output: stdout });

    const name: string = await rl.question("What is your name? ");
    const answer: string = await rl.question("How many files will you create? ");
    rl.close();

    // Input always arrives as a string; converting it is our job
    const count: number = parseInt(answer, 10);

    if (Number.isNaN(count)) {
        console.log(`Hello, ${name}! That wasn't a number, but welcome anyway.`);
    } else {
        console.log(`Hello, ${name}! Preparing ${count} file(s).`);
    }
}

main();

Because rl.question returns Promise<string>, TypeScript would reject answer * 2 at compile time — you must convert deliberately and handle the failure case, which is exactly the kind of bug static typing catches early.

Reading and Writing Files

The node:fs/promises module provides asynchronous file operations. Combined with an interface, TypeScript can type-check data all the way from your program to disk and back via JSON.

Create a file named file_io.ts:

 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
import { readFile, writeFile, appendFile } from "node:fs/promises";

interface LogEntry {
    timestamp: string;
    message: string;
}

async function main(): Promise<void> {
    // writeFile creates the file, or overwrites it if it exists
    await writeFile("journal.txt", "First entry\n", "utf-8");

    // appendFile adds to the end without overwriting
    await appendFile("journal.txt", "Second entry\n", "utf-8");

    // With an encoding, readFile resolves to a string
    const contents: string = await readFile("journal.txt", "utf-8");
    console.log("Journal contents:");
    process.stdout.write(contents);

    // Structured data round-trips through JSON
    const entry: LogEntry = {
        timestamp: "2026-07-14",
        message: "Shipped the release",
    };
    await writeFile("entry.json", JSON.stringify(entry, null, 2) + "\n", "utf-8");

    const parsed: LogEntry = JSON.parse(await readFile("entry.json", "utf-8"));
    console.log(`Parsed entry: ${parsed.message} (${parsed.timestamp})`);
}

main();

Each await suspends the function while Node.js performs the disk operation, keeping the runtime free to do other work — the idiomatic non-blocking style of the platform.

Handling I/O Errors with Type Narrowing

File operations fail in the real world: missing files, permission problems, full disks. In TypeScript a caught error has type unknown, so you must narrow it before touching its properties — the compiler forces you to handle errors deliberately.

Create a file named safe_read.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { readFile } from "node:fs/promises";

async function main(): Promise<void> {
    try {
        const data: string = await readFile("missing.txt", "utf-8");
        console.log(data);
    } catch (err: unknown) {
        // Narrow from unknown before accessing any properties
        if (err instanceof Error && "code" in err && err.code === "ENOENT") {
            console.log("File not found: missing.txt");
        } else {
            throw err;
        }
    }
}

main();

The instanceof Error check and the "code" in err check are both type guards. Only after them will the compiler let you read err.code — a small example of how TypeScript turns runtime error-handling discipline into a compile-time requirement.

Running with Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Pull the official image
docker pull node:22-alpine

# Run the console output example
docker run --rm -v $(pwd):/app -w /app node:22-alpine sh -c "npx -y tsx console_output.ts"

# Run the stdin example (-i keeps stdin open for interactive input)
docker run --rm -i -v $(pwd):/app -w /app node:22-alpine sh -c "npx -y tsx read_input.ts"

# Run the file I/O example (files are written to your mounted directory)
docker run --rm -v $(pwd):/app -w /app node:22-alpine sh -c "npx -y tsx file_io.ts"

# Run the error handling example
docker run --rm -v $(pwd):/app -w /app node:22-alpine sh -c "npx -y tsx safe_read.ts"

Because the current directory is mounted at /app, files created by file_io.ts (journal.txt and entry.json) appear in your working directory on the host.

Expected Output

Running console_output.ts:

Standard output with console.log
TypeScript first appeared in 2012
Building a line piece by piece
Name: TypeScript, Year: 2012
Warnings and errors go to stderr

Running read_input.ts (a sample interactive session, with Ada and 3 typed at the prompts):

What is your name? Ada
How many files will you create? 3
Hello, Ada! Preparing 3 file(s).

Running file_io.ts:

Journal contents:
First entry
Second entry
Parsed entry: Shipped the release (2026-07-14)

Running safe_read.ts:

File not found: missing.txt

Key Concepts

  • I/O comes from the runtime, not the language — TypeScript types Node.js APIs like node:fs and node:readline; the same TypeScript code targeting a browser would use entirely different I/O (fetch, DOM).
  • stdout vs stderrconsole.log and process.stdout.write go to standard output; console.error goes to standard error, so pipelines can separate data from diagnostics.
  • process.stdout.write omits the newline — use it when building a line incrementally; console.log always appends one.
  • Asynchronous by defaultnode:fs/promises and node:readline/promises return typed promises, and async/await keeps the code readable while I/O stays non-blocking.
  • Input is always a stringrl.question resolves to string; TypeScript makes the conversion to number (and the NaN check) an explicit step instead of a silent coercion.
  • Caught errors are unknown — you must narrow with type guards like instanceof Error and "code" in err before reading properties, so error handling is checked at compile time.
  • Interfaces type your file data — a LogEntry interface lets structured data round-trip through JSON.stringify and JSON.parse with a declared shape.
  • Encoding matters — passing "utf-8" to readFile yields a string; omitting it yields a Buffer of raw bytes, and the type system tracks the difference.

Running Today

All examples can be run using Docker:

docker pull node:22-alpine
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining