Telex logo Telex

Building a Chat Server in Rust #1: Hello, TCP

Post 1 of 6 in Building a Chat Server in Rust. Companion series: Rust Patterns That Matter.

Next: #2: Rooms and Users

We're building a chat server. Not a toy - a real TCP server that accepts connections, parses messages, handles errors, and will eventually support rooms, commands, threads, and async. But we start simple: accept a connection, read lines, parse them, echo them back.

By the end of this post, you'll have a working server and three Rust patterns under your belt. The code is on the 01-hello-tcp branch.

The bare minimum

A TCP server in Rust fits in 20 lines. Bind a socket, accept connections, read lines, write them back:

use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let listener = TcpListener::bind("127.0.0.1:8080")?;

    for stream in listener.incoming() {
        let mut stream = stream?;
        let reader = BufReader::new(stream.try_clone()?);
        for line in reader.lines() {
            let line = line?;
            writeln!(stream, "echo: {line}")?;
        }
    }

    Ok(())
}

This works. Connect with nc 127.0.0.1 8080, type a line, see it echoed back. But it's fragile: errors crash the server, there's no structure to messages, and every integer is just an integer. Let's fix all three.

Pattern #1: Newtypes - making the compiler your ally

Our server will have users and rooms. Both will have integer IDs. Without newtypes, both are u64, and the compiler can't tell them apart:

fn register_client(id: u64, room: u64) { /* ... */ }

// Oops: which is the user, which is the room?
register_client(room_id, client_id);

Wrap each in a single-field tuple struct and the bug becomes a compile error:

use std::fmt;

/// A unique identifier for a connected user.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(u64);

impl UserId {
    pub fn next(counter: &mut u64) -> Self {
        let id = *counter;
        *counter += 1;
        Self(id)
    }
}

impl fmt::Display for UserId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "user#{}", self.0)
    }
}

UserId and RoomId are different types now. Same representation at runtime (u64), zero overhead, but the compiler rejects mix-ups. We define RoomId the same way - it'll be used from Stage 2 onward.

Deep dive: Rust Patterns #1: Newtype covers the full pattern including the orphan rule workaround.

Pattern #2: From / Into - a message wire format

Our server needs a message format. Something simple: username:message body\n. A client sends alice:hello world\n and the server parses it into a structured Message.

use std::fmt;
use std::str::FromStr;

#[derive(Debug, Clone)]
pub struct Message {
    pub username: String,
    pub body: String,
}

Parsing can fail - the line might not have a : delimiter, or the username might be empty. So we implement FromStr, which lets us write line.parse::<Message>() and get a Result back:

impl FromStr for Message {
    type Err = ChatError;

    fn from_str(line: &str) -> Result<Self, Self::Err> {
        let (username, body) = line
            .split_once(':')
            .ok_or_else(|| ChatError::Parse("missing ':' delimiter".into()))?;

        let username = username.trim();
        if username.is_empty() {
            return Err(ChatError::Parse("empty username".into()));
        }

        Ok(Message {
            username: username.to_string(),
            body: body.to_string(),
        })
    }
}

For the other direction - turning a Message into a string for display - we implement Display:

impl fmt::Display for Message {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "<{}> {}", self.username, self.body)
    }
}

The conversion chain: raw bytes arrive on the TCP socket, BufReader gives us String lines, .parse() gives us a Message, and Display formats it for output. Each step has a clear type and a clear conversion.

Deep dive: Rust Patterns #2: From / Into Conversions covers the full From/Into ecosystem, including why implementing From gives you Into for free.

Pattern #3: Error handling - ? and thiserror

Our server has two kinds of errors: network failures (io::Error) and parse failures (bad message format). We define an enum that covers both:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ChatError {
    #[error("network error: {0}")]
    Network(#[from] std::io::Error),

    #[error("parse error: {0}")]
    Parse(String),
}

thiserror generates the Display and Error trait implementations from the #[error(...)] attributes. #[from] generates From<io::Error> for ChatError, which means the ? operator automatically converts IO errors into our error type.

This is the key insight: ? calls .into() on the error. With the From impl in place, every ? on an IO operation does the conversion for free:

let listener = TcpListener::bind("127.0.0.1:8080")?;  // io::Error → ChatError::Network
let mut stream = stream?;                               // io::Error → ChatError::Network
let line = line?;                                        // io::Error → ChatError::Network

Deep dive: Rust Patterns #3: Error Handling covers thiserror vs anyhow and when to use each.

Putting it together

Here's the full server. It accepts connections one at a time (single-threaded for now), assigns each a UserId, reads lines, parses them into Messages, and echoes them back formatted:

mod error;
mod message;
mod types;

use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;

use error::ChatError;
use message::Message;
use types::UserId;

fn main() -> Result<(), ChatError> {
    let listener = TcpListener::bind("127.0.0.1:8080")?;
    println!("Chat server listening on 127.0.0.1:8080");

    let mut next_user_id = 0u64;

    for stream in listener.incoming() {
        let mut stream = stream?;
        let peer = stream.peer_addr()?;
        let user_id = UserId::next(&mut next_user_id);
        println!("[{user_id}] connected from {peer}");

        if let Err(e) = handle_client(&mut stream, user_id) {
            println!("[{user_id}] error: {e}");
        }

        println!("[{user_id}] disconnected");
    }

    Ok(())
}

fn handle_client(
    stream: &mut std::net::TcpStream,
    user_id: UserId,
) -> Result<(), ChatError> {
    writeln!(stream, "Welcome, {user_id}! Format: username:message")?;

    let reader = BufReader::new(stream.try_clone()?);
    for line in reader.lines() {
        let line = line?;
        match line.parse::<Message>() {
            Ok(msg) => {
                println!("[{user_id}] {msg}");
                writeln!(stream, "{msg}")?;
            }
            Err(e) => {
                writeln!(stream, "ERROR: {e}")?;
            }
        }
    }

    Ok(())
}

Try it

# Terminal 1
git clone https://github.com/telex-tui/rust-chat-server
git checkout 01-hello-tcp
cargo run

# Terminal 2
nc 127.0.0.1 8080
alice:hello world          # → <alice> hello world
bob:good morning           # → <bob> good morning
no delimiter here          # → ERROR: parse error: missing ':' delimiter

What we have, what's missing

We have a server that compiles, runs, and handles errors without crashing. Three patterns are already working:

What's missing: the server handles one connection at a time. There are no rooms, no broadcasting, no shared state. That's the next post - and it's where ownership gets interesting.