There is a version of Rust that most tutorials skip over. It does not use raw pointers. It does not have explicit lifetime annotations. It calls .clone() without apology. It reaches for Arc<Mutex<T>> instead of carefully threading borrows through a call stack. It looks, from a certain angle, like Rust with the interesting parts removed.
That version of Rust is worth taking seriously. The article by hamy.xyz makes the case directly: you can get most of what Rust offers — compile-time memory safety, no data races, no null, strong static types, first-class error handling — by learning a deliberately restricted subset of the language. The hard parts of Rust are not uniformly distributed across the language; they cluster around specific goals, and if your goal is not zero-copy parsing or embedded firmware, you can largely sidestep them.
This is worth unpacking in more detail than a single article can cover, because the claim is both correct and frequently misunderstood.
Where Rust’s Complexity Actually Lives
Rust’s notorious difficulty comes from three interrelated features: the borrow checker, explicit lifetimes, and the ownership model’s interaction with concurrent code. These are not separate problems — they are all consequences of the same design decision: Rust tracks, at compile time, exactly how long every piece of memory is valid and who is allowed to read or modify it.
That tracking is what makes Rust safe without a garbage collector. It is also what makes borrowing a String into a struct field a compile error unless you annotate the lifetime of the borrow, and what causes the compiler to reject code that holds a reference across an .await point in an async function.
The key observation is that this complexity is mostly load-bearing for one specific goal: zero-overhead abstraction over memory. If you are writing a parser that operates on borrowed slices of an input buffer, you need lifetimes. If you are building a data structure that stores references into another data structure, you need lifetimes. If you are passing borrows into threads, you need to prove at compile time that the referenced data outlives the thread.
If you are building a web server, a CLI tool, a Discord bot, or a data pipeline, you are almost certainly not doing any of those things.
The High-Level Subset
The high-level approach to Rust rests on a small set of substitutions. Instead of borrowed references, use owned types. Instead of explicit lifetime annotations, return owned values and let the borrow checker track ownership without needing guidance. Instead of carefully threading borrows through concurrent code, use Arc<T> to share ownership atomically.
In practice, this means defaulting to String over &str in struct fields, Vec<T> over &[T] in most positions, and HashMap<K, V> owned outright rather than borrowed. For function parameters that only read a string and do not store it, &str is still the right call — it is idiomatic, and the borrow checker handles it without any annotation needed. But for anything that gets stored, or moved into an async task, or sent to a thread, owned types are far simpler.
// High-level struct: all fields owned, no lifetime annotation needed
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MessageContext {
channel_id: String,
author: String,
content: String,
attachments: Vec<String>,
}
// Function that only reads: borrow the string, no lifetime annotation needed
fn should_respond(content: &str) -> bool {
content.starts_with('!')
}
// Function that stores into a struct: take ownership
fn build_context(channel_id: String, author: String, content: String) -> MessageContext {
MessageContext { channel_id, author, content, attachments: vec![] }
}
The #[derive] line in that struct is doing substantial work. Clone means the struct can be duplicated without manual implementation. Debug means it prints sensibly. Serialize and Deserialize from serde mean it can be serialized to JSON, TOML, or any other format without a single hand-written conversion. This is not a systems programming pattern; it is high-level ergonomics that happens to be built on Rust’s type system rather than a runtime.
Cloning Without Guilt
Early Rust culture treated .clone() as evidence of failure — a sign that you did not understand ownership and were working around the borrow checker rather than through it. That culture has largely shifted, but not entirely.
The pragmatic view: clone at boundaries, borrow everywhere else. When you need to move a value into an async task, clone it. When you need to store a string in a struct but only have a reference, clone it. When the alternative is annotating a lifetime through three layers of function calls, clone it and profile later.
// Clone at the boundary -- the async task needs ownership
let channel_id = context.channel_id.clone();
tokio::spawn(async move {
log_message(channel_id, timestamp).await;
});
// Borrow inside a loop -- no allocation, no problem
for message in &history {
if is_command(&message.content) {
process_command(&message.content);
}
}
The performance cost of cloning is O(n) in the length of the string or vector being copied. For most application code, this is in the noise: web server throughput is network-bound; CLI tools are I/O-bound; bot event loops are Discord API-bound. The allocation you are worried about is not the allocation that is slowing you down.
When you are writing a high-throughput message parser or a hot loop that processes millions of items per second, the calculus changes. Profile first. The code will tell you where the allocations are, and then you can add borrows and lifetimes precisely where they pay for themselves.
Shared State with Arc
The hardest part of concurrent Rust is sharing mutable state across threads or async tasks. The borrow checker enforces that you cannot have two mutable references to the same data simultaneously, which is exactly the right constraint — it is why Rust has no data races. But satisfying it in concurrent code is non-trivial when you want multiple tasks to read and write shared state.
The high-level answer is Arc<Mutex<T>> or Arc<RwLock<T>>. Arc gives you shared ownership across threads via an atomic reference count; Mutex or RwLock gives you exclusive access at runtime. The compile-time guarantee becomes: you cannot access the data without acquiring the lock. Data races are impossible because the compiler enforces it at the type level.
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
// Shared state that multiple handlers can read and write
#[derive(Clone)]
struct BotState {
user_data: Arc<RwLock<HashMap<String, UserProfile>>>,
config: Arc<Config>,
}
// Clone the state to pass into a handler -- Arc::clone is cheap (atomic refcount)
async fn handle_message(state: BotState, message: Message) {
let profile = {
let map = state.user_data.read().await;
map.get(&message.author).cloned()
};
// ...
}
Arc::clone() increments an atomic counter. It does not copy the underlying data. The BotState::clone() here clones two Arc handles and leaves the underlying RwLock and Config untouched. This is a common point of confusion: cloning an Arc<T> is fundamentally different from cloning a String. The #[derive(Clone)] on BotState does the right thing automatically because both fields implement Clone in the shallow, reference-count-bumping way.
Error Handling Done Right
Rust’s error handling is excellent in theory and sometimes verbose in practice. The standard library’s std::error::Error trait is deliberately minimal, and building error types by hand for every module is tedious.
Two crates solve this cleanly for application code. anyhow provides a single anyhow::Error type that wraps any error with context, using the ? operator for propagation and .context() for annotating errors with human-readable messages:
use anyhow::{Context, Result};
async fn load_channel_config(channel_id: &str) -> Result<ChannelConfig> {
let path = format!("config/{}.json", channel_id);
let text = tokio::fs::read_to_string(&path)
.await
.with_context(|| format!("Reading config for channel {}", channel_id))?;
let config: ChannelConfig = serde_json::from_str(&text)
.context("Parsing channel config JSON")?;
Ok(config)
}
The ? operator propagates errors upward. The .context() call adds a message that will appear in the error chain when the error is eventually printed. The return type Result<ChannelConfig> is anyhow::Result, which is std::result::Result<T, anyhow::Error>. No custom error enum, no impl From<serde_json::Error>, no manual error conversion.
thiserror is the complementary tool for library code, where you want to expose structured error types that callers can match on. For application code, anyhow is almost always sufficient.
What You Still Get
The high-level approach does not mean abandoning Rust’s guarantees. The borrow checker still prevents use-after-free bugs. The type system still makes null pointer dereferences impossible — there is no null in Rust; optional values are explicit Option<T>, and the compiler requires you to handle both cases. Data races are still impossible, even with Arc<Mutex<T>>, because the type system enforces lock acquisition before access.
You also get Rust’s tooling. cargo handles dependencies, builds, tests, and benchmarks in a unified tool. clippy provides lint warnings that are consistently useful rather than pedantic. rustfmt formats code deterministically. The compiler’s error messages name the specific rule being violated and often suggest the fix.
And you get Rust’s performance ceiling. High-level Rust code is not as fast as maximally optimized systems Rust, but it is significantly faster than equivalent Python or Ruby and roughly comparable to Go for most application workloads. The extra allocations from owned types and occasional clones are measured in microseconds, not milliseconds.
The Path Forward
The case for learning high-level Rust first is not that lifetimes and borrows are useless. They are genuinely powerful tools for the problems they solve. The case is that they are not the right starting point for someone building application code, and learning them first obscures how much Rust you can write without them.
Start with String, Vec, HashMap, Option, Result, and the ? operator. Use #[derive(Clone, Debug, Serialize, Deserialize)] on every data struct. Use Arc<RwLock<T>> for shared state. Use anyhow for errors. Write application code. Observe that the borrow checker, without a single lifetime annotation, catches real bugs: use-after-move errors, double-frees, data races that would silently corrupt state in any other language.
Once that is comfortable, the path to systems-level Rust — borrowed slices, lifetime annotations, zero-copy parsing — is well-documented in The Rust Book and the Rustonomicon for unsafe code. Those chapters make more sense after you have internalized ownership. They are much harder to absorb as a first introduction.
The 80/20 framing in the original article is accurate, with one refinement: the 80% of benefits you get from high-level Rust is not a consolation prize. For most software, it is exactly the part that matters.