Photo by Gradienta on Unsplash
While Rust has a concept of interface, it differs from other programming languages in that it does not use the interface
keyword to specify the behavior of classes and functions. Instead, Rust’s closest abstraction pattern is trait
. Although these concepts have many differences, they both address the problem of having multiple possible implementations.
In this blog post, I will compare a TypeScript code snippet with its potential Rust equivalent to demonstrate how to achieve a simple flexible and composable code.
Declaration
Let’s imagine a project that involves saving and listing documents and images in a database. Since both types of files are stored in the same storage and share common characteristics, we could use an interface
to share this common information.
Using an interface would allow us to define a set of properties and methods that are common, making it easier to write code that works with either type of file.
In TypeScript, we can define this interface as follows:
interface Entity {
id: string;
timestamp: number;
}
interface Document extends Entity {
revised: boolean;
}
interface Image extends Entity {
type: string;
}
Since Rust does not have inheritance, the simplest corresponding implementation would require us to duplicate the types.
struct Document {
id: String,
timestamp: u64,
revised: bool,
}
struct Image {
id: String,
timestamp: u64,
mime_type: String,
}
Inheritance and Generic
Let’s now consider a scenario where we want to find a specific document or image. In TypeScript, we can write the code to accomplish this as follows:
const getDocument = (id: string, documents: Document[]): Document | undefined =>
documents.find(({ id: docId }) => docId === id);
const getImages = (id: string, images: Image[]): Image | undefined =>
images.find(({ id: imageId }) => imageId === id);
However, since both types implement the same interface, we can avoid duplication by extracting a generic function:
const get = <T extends Entity>(id: string, elements: T[]): T | undefined =>
elements.find(({ id: elementId }) => elementId === id);
const getDocument = (id: string, documents: Document[]): Document | undefined =>
get<Document>(id, documents);
const getImages = (id: string, images: Image[]): Image | undefined => get<Image>(id, images);
In Rust, implementing the same functionality would initially also require duplicating the code:
fn get_document(id: String, documents: Vec<Document>) -> Option<Document> {
documents.into_iter().find(|document| document.id == id)
}
fn get_image(id: String, images: Vec<Image>) -> Option<Image> {
images.into_iter().find(|image| image.id == id)
}
As you can see, the Rust code closely resembles the TypeScript implementation. However, since Rust does not have inheritance or an interface
keyword, we cannot exactly replicate the same pattern as above to avoid duplication. This is where trait
come into play.
In this particular example, the common trait shared by both documents and images is that they can be compared using IDs. This is why we can declare this characteristic as a trait
and provide a respective implementation for both ```
struct.
trait Compare {
fn compare(&self, id: &str) -> bool;
}
impl Compare for Document {
fn compare(&self, id: &str) -> bool {
self.id == id
}
}
impl Compare for Image {
fn compare(&self, id: &str) -> bool {
self.id == id
}
}
Finally, we can extract the common code to a generic function in Rust, just as we did earlier in TypeScript.
fn get<T: Compare>(id: String, elements: Vec<T>) -> Option<T> {
elements.into_iter().find(|element| element.compare(&id))
}
fn get_document(id: String, documents: Vec<Document>) -> Option<Document> {
get(id, documents)
}
fn get_image(id: String, images: Vec<Image>) -> Option<Image> {
get(id, images)
}
It’s worth noting that trait
in Rust can be combined using the “+” symbol, allowing us to define multiple common characteristics. For example:
fn get<T: Compare + OtherTrait>(id: String, elements: Vec<T>) -> Option<T> {
elements
.into_iter()
.find(|element| element.compare(&id) && element.other_trait(&id))
}
Such pattern is also interesting to implement comparison of objects, as both parameter of the trait
function can related to the same struct.
trait Compare {
fn sort(&self, other: &Self) -> Ordering;
}
impl Compare for Document {
fn sort(&self, other: &Self) -> Ordering {
self.timestamp.cmp(&other.timestamp)
}
}
Conclusion
While we have only scratched the surface of the power that trait
can offer, I hope that this brief tutorial will prove useful to my fellow JavaScript developers who, like me, are exploring Rust.
To infinity and beyond
David