The settled argument for high-level Rust is about tradeoffs: trade some performance ceiling for ergonomics, keep compile-time memory safety and race prevention, ship application code without fighting the borrow checker. The original case at hamy.xyz makes this clearly, and it is correct.
What the framing consistently underweights is a third category. High-level Rust, written with owned types, liberal cloning, and Arc<Mutex<T>> for shared state, provides a set of type system features that mainstream alternatives cannot match. These are not rewards unlocked after mastering lifetimes. They are baseline Rust, available from day one, and they add a class of correctness that is separate from the memory safety and null safety discussion.
The Newtype Pattern and the End of ID Confusion
Discord’s API identifies almost everything with 64-bit integers: users, channels, guilds, messages, roles, webhooks. At the wire level, they are all u64. In Go or Python, they are all uint64 or int, and the type system makes no distinction between them. Nothing prevents passing a channel ID to a function that expects a user ID; both arguments are the same type.
In Rust, wrapping them costs nothing at runtime:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ChannelId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct GuildId(pub u64);
Each type is exactly a u64 at runtime. Zero overhead: no vtable, no boxing, no additional memory layout change. But at compile time, they are distinct types, and the compiler refuses to substitute one for another.
async fn send_message(channel: ChannelId, content: String) -> anyhow::Result<()> {
// ...
}
let user = UserId(123456789);
send_message(user, "hello".to_string()).await?;
// error[E0308]: expected `ChannelId`, found `UserId`
The newtype pattern involves no lifetime annotations. The borrow checker is not involved. Each wrapper derives Clone and Copy the same way any primitive-backed struct does. Adding this to a project takes ten minutes and prevents a category of bugs that code review reliably misses, because the IDs look identical in log output and are easy to transpose in function arguments.
TypeScript’s branded types approximate this, but TypeScript’s guarantees dissolve at the JSON boundary. Anything deserialized from a Discord API response is number until explicitly asserted otherwise, and those assertions are the developer’s responsibility. Go has no equivalent mechanism; you can define a named type like type ChannelID uint64, but Go’s implicit conversion rules mean that an untyped integer literal satisfies the named type without a cast. Rust requires explicit construction: ChannelId(123), not 123.
The enforcement comes from the same compiler that produces the binary, with no separate step and no runtime assertion. The error surfaces in the build, not in a test that only runs when the affected code path executes.
RAII and the Drop Trait
Go defers cleanup with defer, which runs when the enclosing function returns. The mechanism works, but it is function-scoped and advisory; forgetting the defer compiles without warning. Python’s context managers work well inside with blocks, and objects with __del__ methods get cleaned up eventually by the garbage collector, or when the interpreter calls them, or sometimes not at all if the process exits abruptly.
Rust’s Drop trait runs deterministically when a value goes out of scope, and the timing is exact because the compiler tracks when every value’s lifetime ends. This applies to owned types regardless of whether the code uses lifetime annotations or not. The cleanup is structural, not advisory.
For a bot that holds database connections, file handles, or network sockets, every type in Rust’s standard library and the major crates already implements Drop correctly. sqlx::PgPool cleans up its connection pool. tokio::fs::File flushes and closes the file handle. reqwest::Client manages its connection pool internally. These guarantees hold whether the enclosing function returns normally, exits early via ?, or unwinds from a panic.
The consequence for high-level Rust is that resource leaks require deliberate effort to introduce. You have to explicitly mem::forget a value or store it in a Box::leak to prevent Drop from running. In Go and Python, the common paths include resource leaks: forgetting a defer, failing to close a connection pool in the right cleanup function, exiting through a code path that skips the cleanup. Rust removes those paths at the language level, and this has nothing to do with how many .clone() calls are in the code.
Type State Without Phantom Complexity
Rust permits types to carry compile-time state through type parameters, which enables state machines where invalid transitions are not methods that return errors but methods that simply do not exist. A simplified version of this fits comfortably in high-level Rust, without any unsafe code or lifetime annotation.
Consider a Discord bot client that requires authentication before it can send messages:
pub struct Disconnected;
pub struct Connected { pub guild_id: GuildId }
pub struct BotClient<S> {
http: reqwest::Client,
token: String,
state: S,
}
impl BotClient<Disconnected> {
pub fn new(token: String) -> Self {
BotClient {
http: reqwest::Client::new(),
token,
state: Disconnected,
}
}
pub async fn connect(self, guild_id: GuildId) -> anyhow::Result<BotClient<Connected>> {
// authenticate against Discord API
Ok(BotClient {
http: self.http,
token: self.token,
state: Connected { guild_id },
})
}
}
impl BotClient<Connected> {
pub async fn send_message(&self, channel: ChannelId, content: String) -> anyhow::Result<()> {
// only callable on a connected client
todo!()
}
pub fn guild_id(&self) -> GuildId {
self.state.guild_id
}
}
send_message does not exist on BotClient<Disconnected>. Calling it is a compile error: the method is defined only on BotClient<Connected>. Disconnected is a zero-sized struct, so it has no runtime representation. The state is encoded entirely in the type parameter, which the compiler erases after checking.
The type parameter S here is a generic. Generics are part of introductory Rust. No lifetime parameters are involved; neither Disconnected nor Connected borrows anything. The anyhow::Result return type handles error propagation without a custom error enum. The pattern integrates cleanly with the rest of the high-level stack.
Go has no mechanism for restricting method availability based on struct state at compile time. You can split a type into two structs and remove the method from one, which is functionally equivalent, but the technique requires discipline to apply consistently and produces no compiler enforcement when bypassed. TypeScript can encode this with conditional types and discriminated unions, though the implementation is more involved to write and does not produce the same cross-module enforcement. The error in Rust is a build failure at the call site, not a type error that requires the TypeScript compiler to be invoked separately in CI.
Exhaustive Matching on Owned Data
Adding a new variant to a Rust enum is a breaking change that the compiler surfaces at every non-exhaustive match, regardless of whether the data inside the enum is owned or borrowed. When a Discord bot’s internal event enum gains a new variant, every handler that does not cover it fails to compile:
#[derive(Debug, Clone)]
pub enum BotEvent {
MessageCreated { channel: ChannelId, author: UserId, content: String },
ReactionAdded { message_id: u64, user: UserId, emoji: String },
MemberJoined { guild: GuildId, user: UserId },
}
fn dispatch(event: BotEvent) {
match event {
BotEvent::MessageCreated { .. } => handle_message(event),
BotEvent::ReactionAdded { .. } => handle_reaction(event),
// error[E0004]: non-exhaustive patterns: `BotEvent::MemberJoined { .. }` not covered
}
}
The correction happens before shipping, not when an authorized user triggers the unhandled branch in production. And crucially, the data inside each variant is fully owned here; content: String, emoji: String, no lifetime annotations anywhere. Exhaustive checking is orthogonal to owned versus borrowed.
Go’s closest equivalent is an interface with a type switch, which is not checked for exhaustiveness by the compiler. Python’s match statement introduced in 3.10 is structurally exhaustive with a catch-all, but static analysis tools need to run separately to flag missing cases, and they are not universally adopted. TypeScript’s discriminated unions with strict settings come closest, but they are a TypeScript-layer guarantee that the JavaScript runtime beneath does not share.
The Sum
These four features, newtypes, deterministic resource cleanup, type-state encoding, and exhaustive enums, are all accessible in high-level Rust. None requires understanding 'a lifetime parameters or zero-copy borrowing. None is undermined by calling .clone() elsewhere in the codebase. They are part of what comes with Rust by default, not part of the advanced layer unlocked through deep ownership expertise.
The 80/20 argument is accurate about the pain distribution: most of the borrow checker’s friction is concentrated in patterns that application code rarely needs. The missing piece in the standard framing is that the 80% you get includes a type system that is categorically more expressive than Go’s, and that the performance you defer by cloning freely does not come out of the type safety budget.
For developers weighing Rust against Go for service or bot code, the comparison is often made in terms of memory safety and null safety. Those are real advantages. So is the ability to make domain invariants, resource lifetimes, and state transitions unrepresentable as errors rather than just undocumented conventions. That last category of correctness comes with the language, not with the learning curve.