A simple Rust trait

2026-03-26

As I mentioned in my last post, we’ll use this one to explore Rust’s traits.

The first part of migrating that project is migrating the data into a more local alternative. I used Airtable initially because I thought the only user this app had would be happy to go into Airtable and update some things themselves, like the price of a product. Instead, they never did, and all that complexity was for nothing.

So, with this re-write, I’m moving the data from Airtable into an SQLite database. I wrote a purpose-built CLI to do that, in Rust, as part of my learning process.

After writing code, to import the first table, I asked Claude to do it for two other tables. Quickly, there was repetition, so I challenged myself to write an abstraction that could avoid that repetition.

The import of a single table is somewhat simple: - validate the loaded data (from the json) - ensure the target table exists and is empty - convert the data from the format it had in json, to the format I want it to have in SQLite - insert the records

So my first intuition was to write a trait for that, and have one implementation per target table.

So I began scaffolding my trait (put aside the async for a moment):

trait Migratable {
    fn validate(records: AirtableRecords<X>) -> Result<()>;
    fn check_table() -> Result<()>;
    fn convert(one: AirtableRecord<X>) -> Result<Y>;
    fn insert(one: Y) -> Result<()>;
}

As I was laying down the structure of my trait (in a very Java/Scala’ish way), I found that I’d need to deal with generics. In the snippet above, I need to carry a type X for the type of records I read from the Airtable extract and a type Y for the type I want to convert to.

I don’t really mind the generic because I’ve seen my fair share of them in Scala. But how does it work when you want to create an implementation of Migratable and how does it work when you want to implement a function that uses this implementation.

So I tried to write an implementation for the Migratable trait. So I wrote:

impl Migratable<ConfigFields, ConfigRow> {
    
}

As I’m typing, code-analyzer, in my IDE, warns me that I’m trying to do a dyn trait and so I should be explicit: impl dyn Migratable.... When I add it, the compiler tells me that Migratable cannot be a dyn trait.

At this point, I figured I might be doing something wrong. Looking at the chapter 10 of the book, I start to realize that maybe traits in Rust are not exactly like traits in Scala. The syntax impl Summary for NewsArticle is new to me.

What would I put after the for in my case: impl Migratable<ConfigFields, ConfigRow> for ??? ?

Realizing that I can use trait to add behavior to a particular type, I began to rethink how I’d implement this abstraction. Instead of going for a trait right off the bat, let’s instead try to implement a function that does the 3-4 things I need to export a table, and then use a trait to implement the custom, per type, only when I need it.

So I began to write this function:

fn insert_records<T>(data: AirtableRecords<T>) -> Result<()> {
    Ok(())
}

First thing I need is validating:

fn insert_records1<T>(data: AirtableRecords<T>) -> Result<()> {
    for r in data.records {
        validate(r);
    }
    Ok(())
}

The code above is invalid because I don’t have a validate function. Validation is custom per type, so let’s write a trait for it:

trait Validate {
    fn validate(self) -> Result<()>;
}

and replaced the call from validate(r) to r.validate()?. The code is still red, but the compiler is helpful:

    = help: items from traits can only be used if the trait is implemented and in scope
note: `Validate` defines an item `validate`, perhaps you need to implement it
   --> src/cli/migration.rs:176:1

Then, I write an implementation, but again, I have to carry the generic T:

impl <T> Validate for AirtableRecord<T> {
    fn validate(self) -> Result<()> {
        todo!()
    }
}

This code is valid, but how can I implement the validation with a generic T here? Instead I removed the T from the impl and stuck a concrete type in there instead: impl Validate for AirtableRecord<ConfigFields>. I went back to the error from above, but I wondered if there was a mechanism to enforce that T in my insert_records implements Validate.

In the book, I found trait bounds for that, so I naively tried: where T: Validate, but it did not work.

The records Vec contains AirtableRecord<T> not T directly. Again the compiler message is helpful:

error[E0599]: no method named `validate` found for struct `AirtableRecord<T>` in the current scope
   --> src/cli/migration.rs:188:11
    |
 23 | pub struct AirtableRecord<T> {
    | ---------------------------- method `validate` not found for this struct
...
188 |         r.validate()?
    |           ^^^^^^^^ method not found in `AirtableRecord<T>`
    |
    = help: items from traits can only be used if the trait is implemented and in scope
note: `Validate` defines an item `validate`, perhaps you need to implement it
   --> src/cli/migration.rs:176:1
    |
176 | trait Validate {
    | ^^^^^^^^^^^^^^
help: one of the expressions' fields has a method of the same name
    |
188 |         r.fields.validate()?
    |           +++++++

The key part is help: one of the expressions' fields has a method of the same name. If I do r.fields.validate()? it works. Because the constraints is on T, not AirtableRecord<T>. To work around this, and keep r.validate()?, I updated the constraint to where AirtableRecord<T>: Validate.

This works, now let’s convert from T to R:

fn insert_records2<T, R>(data: AirtableRecords<T>) -> Result<()> where AirtableRecord<T>: Validate {
    let mut converted: Vec<R> = Vec::new();
    for r in data.records {
            r.validate()?;
            converted.push(r.convert());
    }
    Ok(())
}

Again, the conversion is type specific and as I was about to write a trait with a convert method on it, I remembered using String::from. So I looked at it and saw that the from function came from the From trait in the stdlib.

So instead of having my own trait I just added another constraint:

where
    AirtableRecord<T>: Validate,
    R: From<AirtableRecord<T>>

This worked. There was another problem though, the validate trait I implemented earlier took ownership for no good reason, so I updated it to: fn validate(&self) -> Result<()> instead.

Next, I need to check if the target table exists and is not empty. Again, this behavior depends of the target type R.

So I created a trait TableCheck:

trait TableCheck {
    fn check_table() -> Result<()>;
}
// and its implementation
impl TableCheck for ConfigRow {
    fn check_table() -> Result<()> {
        todo!();
    }
}

I added the trait bound to the function signature and as I was about to try and call check_table, I realized that I did not want to call that per record.

for c in converted {
    c.check_table()?;
}

The above would be wildly inefficient. Instead, I just want to check once, before proceeding with the inserts. Furthermore, without realizing it, I had written check_table without a self parameter, so the c.check_table did not even work. But again, the compiler was helpful:

help: use associated function syntax instead
    |
224 -         c.check_table()?;
224 +         R::check_table()?;
    |

So I updated the implementation to:

fn insert_records<T, R>(data: AirtableRecords<T>) -> Result<()>
where
    AirtableRecord<T>: Validate,
    R: From<AirtableRecord<T>>,
    R: TableCheck {
    let mut converted: Vec<R> = Vec::new();
    for r in data.records {
            r.validate()?;
            converted.push(R::from(r));
    }

    R::check_table()?;
    Ok(())
}

Finally, I need to insert the records, so again I wrote an Insertable trait and used it:

trait Insertable {
    fn insert(&self) -> Result<()>;
}
fn insert_records<T, R>(data: AirtableRecords<T>) -> Result<()>
where
    AirtableRecord<T>: Validate,
    R: From<AirtableRecord<T>>,
    R: TableCheck,
    R: Insertable {
    let mut converted: Vec<R> = Vec::new();
    for r in data.records {
            r.validate()?;
            converted.push(R::from(r));
    }

    R::check_table()?;
    for c in converted {
        c.insert()?;
    }
    Ok(())
}

That’s it, this helped me address duplicated code and learn more about traits. I found out that: