Have you ever written a TypeScript application, and it suddenly crashes on runtime because of some invalid data schema? Yeah, that was tough. It's even more difficult to check the schema of all data you receive. Zod does help, but it's a big library.
Well, if you're tired of that type nonsense and want to worry less about whether you handled every data you receive, then try Rust! It has a Type-driven design pattern, where its compiler cries everytime you don't handle all the possible scenarios which a data can become.
Result Enums
Rust has Enums, which are one of the most powerful features it has. One of the built-in Enums is called "Result", which is used for error handling.
It looks like this internally:
enum Result<T, E> {
Ok(T),
Err(E)
}
Generics in Rust are like generics in Typescript. It can be specified within type parameters, represent anything, and constrained. In the example above, we are saying that the enum Result can have two types, and that is either an Ok or an Error, and it contains a tuple that can be of any type.
To handle that, we can do:
// Say we have a function that returns result
fn can_err() -> Result<String, String> {
Err("I'm an error!".to_string())
}
// Now, say that we call the fn above
fn main() {
let result = can_err();
match result {
Ok(value) => {},
Err(error) => println!("{:#?}", error)
}
}
What you see above is a match statement. We always need to handle every scenario before we can do anything with the return value of a function that can possibly error. Now, the problem with the example above is that we can get to a situation called, "noisy match statements", where we call these statements everywhere and nest them all the time, resulting in an ugly code.
There are many ways to solve this, and the most common one is using the ? operator to propagate the error to the caller of our function.
For example:
fn can_err() -> Result<String, String> {
Err("I'm an error!".to_string())
}
fn some_fn() -> Result<(), String> {
let value = can_err()?;
// () means nothing
Ok(())
}
fn main() {
// Now we just handle the error in this main function.
// We are pretty much saying that if that returned
// value is of Enum member "Err", we execute the code inside the if statement block.
// We also restructure the tuple value that the "Err"
// contains and it can be used inside the if block.
if let Err(error) = some_fn() {
eprintln!("{:#?}", error);
}
}
There are more methods we can do besides using these methods, but I won't go further since there are quite a few.
Now, what if the only time we give out an error is that a value doesn't exist? Or how can we represent a type that can either contain data or be null or undefined in Rust?
Option
Option is a built-in Enum as well, and contains two members.
enum Option<T> {
Some(T),
None
}
We can do the same things we did with the Result Enum, except for the ? operator. There are also many methods for Option to handle them wisely, and also convert it to Result and vice versa, but I won't dive deep into that.
Parse, don't validate
One more thing in Rust is that we should, if possible, parse a received data to the type we expect them to be instead of validating them.
For example:
pub struct Email(pub String);
impl Email {
fn parse(val: String) -> Result<Email, Box<dyn Error>> {
// Perform some validations here...
// Like, checking if the string contains an "@" sign, etc.
Email(val)
}
}
// Now, we can use that:
pub struct EndpointData {
pub email: String
}
fn endpoint(form: Form<EndpointData>) -> Result<Response, Box<dyn Error>> {
let email = Email::parse(form.email.clone)?;
store(email)?;
Response::build().ok()
}
fn store(email: Email) -> Result<(), Box<dyn Error>> {
// Store operations
// You can destructure the Email here to get the String
let Email(email) = email;
}
There are many more ways to make your life easier when reading and writing code in Rust by using its awesome type features, but that will be all for this article.
Thank you for reading until the end, and correct me if I made some mistakes on the examples.
God bless!
Top comments (0)