ExamplesBy LevelBy TopicLearning Paths
525 Intermediate

Tap Pattern for Side Effects

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "Tap Pattern for Side Effects" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. Data pipelines built with iterator chains or method chaining have a readability problem: inserting debug logging or instrumentation requires breaking the chain into temporary `let` bindings. Key difference from OCaml: 1. **Method vs function**: Rust's `Tap` trait enables `value.tap(f)` dot

Tutorial

The Problem

Data pipelines built with iterator chains or method chaining have a readability problem: inserting debug logging or instrumentation requires breaking the chain into temporary let bindings. The tap pattern solves this by injecting a side-effecting function at any point in a chain without disrupting the data flow — the function runs, but the original value passes through unchanged. This pattern appears in JavaScript's .tap() in lodash, Ruby's Object#tap, Haskell's (<$) for constant functors, and is commonly needed when debugging long iterator chains.

🎯 Learning Outcomes

  • • How tap<T, F: FnOnce(&T)>(value: T, f: F) -> T threads a value through a side effect
  • • How to implement tap as an extension trait for ergonomic chaining with dot notation
  • • The difference between tap (immutable peek), tap_mut (mutable modification), and tap_dbg (debug printing)
  • • How tap_if(condition, f) enables conditional side effects without breaking the chain
  • • Where tap appears: logging pipelines, metrics instrumentation, test assertions in chains
  • Code Example

    pub trait Tap: Sized {
        fn tap(self, f: impl FnOnce(&Self)) -> Self {
            f(&self);
            self
        }
    }
    
    impl<T> Tap for T {}
    
    // Usage
    let result = vec![1, 2, 3]
        .into_iter()
        .map(|x| x * 2)
        .collect::<Vec<_>>()
        .tap(|v| println!("doubled: {} items", v.len()));

    Key Differences

  • Method vs function: Rust's Tap trait enables value.tap(f) dot-notation in method chains; OCaml uses |> with tap f as a free function — both achieve the same pipeline clarity.
  • Mutable tap: Rust tap_mut can modify the value in-place before it passes through; OCaml's tap with a ref or mutable record achieves the same but requires explicit ref cells.
  • Debug constraint: Rust's tap_dbg requires T: Debug at compile time; OCaml's tap_debug uses Obj.repr or format functions at runtime with less type safety.
  • Blanket impl: Rust's blanket impl<T> Tap for T adds methods to every type in scope; OCaml achieves this through the module system by opening a Tap module.
  • OCaml Approach

    OCaml achieves tap via a simple helper that is idiomatic and common in pipelines using |>:

    let tap f x = f x; x
    let tap_debug label x = Printf.eprintf "%s: %s\n" label (Obj.repr x |> ...); x
    (* usage *)
    value |> tap (fun x -> log x) |> transform |> tap (fun x -> assert_ok x)
    

    Full Source

    #![allow(clippy::all)]
    //! Tap Pattern for Side Effects
    //!
    //! Inspect values in a pipeline without disrupting the data flow.
    
    /// Tap: run a side effect, then return the value unchanged.
    pub fn tap<T, F: FnOnce(&T)>(value: T, f: F) -> T {
        f(&value);
        value
    }
    
    /// Tap with a mutable reference.
    pub fn tap_mut<T, F: FnOnce(&mut T)>(mut value: T, f: F) -> T {
        f(&mut value);
        value
    }
    
    /// Extension trait to enable chained .tap() calls.
    pub trait Tap: Sized {
        fn tap(self, f: impl FnOnce(&Self)) -> Self {
            f(&self);
            self
        }
    
        fn tap_mut(mut self, f: impl FnOnce(&mut Self)) -> Self {
            f(&mut self);
            self
        }
    
        fn tap_dbg(self, label: &str) -> Self
        where
            Self: std::fmt::Debug,
        {
            eprintln!("{}: {:?}", label, &self);
            self
        }
    }
    
    impl<T> Tap for T {}
    
    /// Debug tap that prints the value.
    pub fn tap_debug<T: std::fmt::Debug>(value: T) -> T {
        eprintln!("DEBUG: {:?}", value);
        value
    }
    
    /// Conditional tap.
    pub fn tap_if<T, F: FnOnce(&T)>(value: T, condition: bool, f: F) -> T {
        if condition {
            f(&value);
        }
        value
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
        use std::cell::RefCell;
    
        #[test]
        fn test_tap_function() {
            let log = RefCell::new(Vec::new());
            let result = tap(42, |x| log.borrow_mut().push(*x));
            assert_eq!(result, 42);
            assert_eq!(*log.borrow(), vec![42]);
        }
    
        #[test]
        fn test_tap_mut_function() {
            let result = tap_mut(vec![1, 2, 3], |v| v.push(4));
            assert_eq!(result, vec![1, 2, 3, 4]);
        }
    
        #[test]
        fn test_tap_trait() {
            let log = RefCell::new(Vec::new());
            let result = 42.tap(|x| log.borrow_mut().push(*x));
            assert_eq!(result, 42);
            assert_eq!(*log.borrow(), vec![42]);
        }
    
        #[test]
        fn test_tap_mut_trait() {
            let result = vec![1, 2, 3].tap_mut(|v| v.push(4));
            assert_eq!(result, vec![1, 2, 3, 4]);
        }
    
        #[test]
        fn test_tap_chain() {
            let log = RefCell::new(Vec::new());
    
            let result = 10
                .tap(|x| log.borrow_mut().push(format!("start: {}", x)))
                .tap(|x| log.borrow_mut().push(format!("value: {}", x)));
    
            assert_eq!(result, 10);
            assert_eq!(log.borrow().len(), 2);
        }
    
        #[test]
        fn test_tap_in_pipeline() {
            let log = RefCell::new(Vec::new());
    
            let result: i32 = [1, 2, 3, 4, 5]
                .iter()
                .map(|&x| x * 2)
                .map(|x| tap(x, |v| log.borrow_mut().push(*v)))
                .sum();
    
            assert_eq!(result, 30);
            assert_eq!(*log.borrow(), vec![2, 4, 6, 8, 10]);
        }
    
        #[test]
        fn test_tap_if() {
            let mut called = false;
            let _ = tap_if(42, true, |_| called = true);
            assert!(called);
    
            called = false;
            let _ = tap_if(42, false, |_| called = true);
            assert!(!called);
        }
    
        #[test]
        fn test_tap_debug() {
            // Just verify it compiles and returns the value
            let result = tap_debug(vec![1, 2, 3]);
            assert_eq!(result, vec![1, 2, 3]);
        }
    
        #[test]
        fn test_tap_preserves_value() {
            let original = String::from("hello");
            let result = original.tap(|s| {
                // Expensive debug operation
                let _ = s.len();
            });
            assert_eq!(result, "hello");
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
        use std::cell::RefCell;
    
        #[test]
        fn test_tap_function() {
            let log = RefCell::new(Vec::new());
            let result = tap(42, |x| log.borrow_mut().push(*x));
            assert_eq!(result, 42);
            assert_eq!(*log.borrow(), vec![42]);
        }
    
        #[test]
        fn test_tap_mut_function() {
            let result = tap_mut(vec![1, 2, 3], |v| v.push(4));
            assert_eq!(result, vec![1, 2, 3, 4]);
        }
    
        #[test]
        fn test_tap_trait() {
            let log = RefCell::new(Vec::new());
            let result = 42.tap(|x| log.borrow_mut().push(*x));
            assert_eq!(result, 42);
            assert_eq!(*log.borrow(), vec![42]);
        }
    
        #[test]
        fn test_tap_mut_trait() {
            let result = vec![1, 2, 3].tap_mut(|v| v.push(4));
            assert_eq!(result, vec![1, 2, 3, 4]);
        }
    
        #[test]
        fn test_tap_chain() {
            let log = RefCell::new(Vec::new());
    
            let result = 10
                .tap(|x| log.borrow_mut().push(format!("start: {}", x)))
                .tap(|x| log.borrow_mut().push(format!("value: {}", x)));
    
            assert_eq!(result, 10);
            assert_eq!(log.borrow().len(), 2);
        }
    
        #[test]
        fn test_tap_in_pipeline() {
            let log = RefCell::new(Vec::new());
    
            let result: i32 = [1, 2, 3, 4, 5]
                .iter()
                .map(|&x| x * 2)
                .map(|x| tap(x, |v| log.borrow_mut().push(*v)))
                .sum();
    
            assert_eq!(result, 30);
            assert_eq!(*log.borrow(), vec![2, 4, 6, 8, 10]);
        }
    
        #[test]
        fn test_tap_if() {
            let mut called = false;
            let _ = tap_if(42, true, |_| called = true);
            assert!(called);
    
            called = false;
            let _ = tap_if(42, false, |_| called = true);
            assert!(!called);
        }
    
        #[test]
        fn test_tap_debug() {
            // Just verify it compiles and returns the value
            let result = tap_debug(vec![1, 2, 3]);
            assert_eq!(result, vec![1, 2, 3]);
        }
    
        #[test]
        fn test_tap_preserves_value() {
            let original = String::from("hello");
            let result = original.tap(|s| {
                // Expensive debug operation
                let _ = s.len();
            });
            assert_eq!(result, "hello");
        }
    }

    Deep Comparison

    OCaml vs Rust: Tap Pattern

    OCaml

    let tap f x = f x; x
    
    (* Usage in pipeline *)
    let result =
      [1; 2; 3]
      |> List.map (fun x -> x * 2)
      |> tap (fun xs -> Printf.printf "doubled: %d items\n" (List.length xs))
      |> List.filter (fun x -> x > 2)
    

    Rust

    pub trait Tap: Sized {
        fn tap(self, f: impl FnOnce(&Self)) -> Self {
            f(&self);
            self
        }
    }
    
    impl<T> Tap for T {}
    
    // Usage
    let result = vec![1, 2, 3]
        .into_iter()
        .map(|x| x * 2)
        .collect::<Vec<_>>()
        .tap(|v| println!("doubled: {} items", v.len()));
    

    Key Differences

  • OCaml: Simple function let tap f x = f x; x
  • Rust: Extension trait for method chaining
  • Both: Inspect values without breaking data flow
  • Rust: Different variants (tap, tap_mut, tap_dbg)
  • Both useful for debugging pipelines
  • Exercises

  • Logged pipeline: Build a pipeline that reads integers from a slice, doubles them, taps to log each doubled value, filters for values over 10, then collects — all in one method chain.
  • Metric tap: Implement tap_count(counter: &mut usize) that increments counter on each call through the chain — useful for profiling how many items pass a filter.
  • Conditional mutation: Use tap_mut to normalize strings (trim whitespace, lowercase) inside a pipeline without breaking the chain, then verify the final collected values are normalized.
  • Open Source Repos