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:
- Newtypes -
UserIdandRoomIdprevent ID mix-ups at zero cost - From / Into -
FromStrfor parsing,Displayfor output,?calls.into()on errors - Error handling -
ChatErrorwiththiserror,?propagation, graceful recovery on parse errors
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.
Telex