Isomorphisms: A Practical Guide

Data Equivalence with Isos

What You'll Learn

  • How to define lossless, reversible conversions between equivalent types
  • Creating isomorphisms with Iso.of(get, reverseGet)
  • Using reverse() to flip conversion directions
  • Step-by-step transformation workflows for data format conversion
  • Testing round-trip properties to ensure conversion correctness
  • When to use isos vs direct conversion methods vs manual adapters

Example Code

In the previous guides, we explored two essential optics: the Lens, for targeting data that must exist (a "has-a" relationship), and the Prism, for safely targeting data that might exist in a specific shape (an "is-a" relationship).

This leaves one final, fundamental question: what if you have two data types that are different in structure but hold the exact same information? How do you switch between them losslessly? For this, we need our final core optic: the Iso.


The Scenario: Translating Between Equivalent Types

An Iso (Isomorphism) is a "two-way street." It's an optic that represents a perfectly reversible, lossless conversion between two equivalent types. Think of it as a universal translator 🔄 or a type-safe adapter that you can compose with other optics.

An Iso is the right tool when you need to:

  • Convert a wrapper type to its raw value (e.g., UserId(long id) <-> long).
  • Handle data encoding and decoding (e.g., byte[] <-> Base64 String).
  • Bridge two data structures that are informationally identical (e.g., a custom record and a generic tuple).

Let's explore that last case. Imagine we have a Point record and want to convert it to a generic Tuple2 to use with a library that operates on tuples.

The Data Model:

public record Point(int x, int y) {}

public record Tuple2<A, B>(A _1, B _2) {}

These two records can hold the same information. An Iso is the perfect way to formalize this relationship.


Think of Isos Like...

  • A universal translator: Perfect two-way conversion between equivalent representations
  • A reversible adapter: Converts between formats without losing information
  • A bridge: Connects two different structures that represent the same data
  • A currency exchange: Converts between equivalent values at a 1:1 rate

A Step-by-Step Walkthrough

Step 1: Defining an Iso

Unlike Lenses and Prisms, which are often generated from annotations, Isos are almost always defined manually. This is because the logic for converting between two types is unique to your specific domain.

You create an Iso using the static Iso.of(get, reverseGet) constructor.

import org.higherkindedj.optics.Iso;
import org.higherkindedj.hkt.tuple.Tuple;
import org.higherkindedj.hkt.tuple.Tuple2;

public class Converters {
    public static Iso<Point, Tuple2<Integer, Integer>> pointToTuple() {
      return Iso.of(
          // Function to get the Tuple from the Point
          point -> Tuple.of(point.x(), point.y()),
          // Function to get the Point from the Tuple
          tuple -> new Point(tuple._1(), tuple._2())
      );
    }
}

Step 2: The Core Iso Operations

An Iso provides two fundamental, lossless operations:

  • get(source): The "forward" conversion (e.g., from Point to Tuple2).
  • reverseGet(target): The "backward" conversion (e.g., from Tuple2 back to Point).

Furthermore, every Iso is trivially reversible using the .reverse() method, which returns a new Iso with the "get" and "reverseGet" functions swapped.

var pointToTupleIso = Converters.pointToTuple();
var myPoint = new Point(10, 20);

// Forward conversion
Tuple2<Integer, Integer> myTuple = pointToTupleIso.get(myPoint); // -> Tuple2[10, 20]

// Backward conversion using the reversed Iso
Point convertedBack = pointToTupleIso.reverse().get(myTuple); // -> Point[10, 20]

// Demonstrate perfect round-trip
assert myPoint.equals(convertedBack); // Always true for lawful Isos

Step 3: Composing Isos as a Bridge

The most powerful feature of an Iso is its ability to act as an adapter or "glue" between other optics. Because the conversion is lossless, an Iso preserves the "shape" of the optic it's composed with.

  • Iso + Iso = Iso
  • Iso + Lens = Lens
  • Iso + Prism = Prism
  • Iso + Traversal = Traversal

This second rule is incredibly useful. We can compose our Iso<Point, Tuple2> with a Lens that operates on a Tuple2 to create a brand new Lens that operates directly on our Point!

// A standard Lens that gets the first element of any Tuple2
Lens<Tuple2<Integer, Integer>, Integer> tupleFirstElementLens = ...;

// The composition: Iso<Point, Tuple2> + Lens<Tuple2, Integer> = Lens<Point, Integer>
Lens<Point, Integer> pointToX = pointToTupleIso.andThen(tupleFirstElementLens);

// We can now use this new Lens to modify the 'x' coordinate of our Point
Point movedPoint = pointToX.modify(x -> x + 5, myPoint); // -> Point[15, 20]

The Iso acted as a bridge, allowing a generic Lens for tuples to work on our specific Point record.


When to Use Isos vs Other Approaches

Use Isos When:

  • Data format conversion - Converting between equivalent representations
  • Legacy system integration - Bridging old and new data formats
  • Library interoperability - Adapting your types to work with external libraries
  • Composable adapters - Building reusable conversion components
// Perfect for format conversion
Iso<LocalDate, String> dateStringIso = Iso.of(
    date -> date.format(DateTimeFormatter.ISO_LOCAL_DATE),
    dateStr -> LocalDate.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE)
);

// Use with any date-focused lens
Lens<Person, String> birthDateStringLens = 
    PersonLenses.birthDate().andThen(dateStringIso);

Use Direct Conversion Methods When:

  • One-way conversion - You don't need the reverse operation
  • Non-lossless conversion - Information is lost in the conversion
  • Performance critical paths - Minimal abstraction overhead needed
// Simple one-way conversion
String pointDescription = point.x() + "," + point.y();

Use Manual Adapters When:

  • Complex conversion logic - Multi-step or conditional conversions
  • Validation required - Conversion might fail
  • Side effects needed - Logging, caching, etc.
// Complex conversion that might fail
public Optional<Point> parsePoint(String input) {
    try {
        String[] parts = input.split(",");
        return Optional.of(new Point(
            Integer.parseInt(parts[0].trim()),
            Integer.parseInt(parts[1].trim())
        ));
    } catch (Exception e) {
        return Optional.empty();
    }
}

Common Pitfalls

❌ Don't Do This:

// Lossy conversion - not a true isomorphism
Iso<Double, Integer> lossyIso = Iso.of(
    d -> d.intValue(),    // Loses decimal precision!
    i -> i.doubleValue()  // Can't recover original value
);

// One-way thinking - forgetting about reverseGet
Iso<Point, String> badPointIso = Iso.of(
    point -> point.x() + "," + point.y(),
    str -> new Point(0, 0)  // Ignores the input!
);

// Creating Isos repeatedly instead of reusing
var iso1 = Iso.of(Point::x, x -> new Point(x, 0));
var iso2 = Iso.of(Point::x, x -> new Point(x, 0));
var iso3 = Iso.of(Point::x, x -> new Point(x, 0));

✅ Do This Instead:

// True isomorphism - perfect round-trip
Iso<Point, String> goodPointIso = Iso.of(
    point -> point.x() + "," + point.y(),
    str -> {
        String[] parts = str.split(",");
        return new Point(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
    }
);

// Test your isomorphisms
public static <A, B> void testIsomorphism(Iso<A, B> iso, A original) {
    B converted = iso.get(original);
    A roundTrip = iso.reverse().get(converted);
    assert original.equals(roundTrip) : "Iso failed round-trip test";
}

// Reuse Isos as constants
public static final Iso<Point, Tuple2<Integer, Integer>> POINT_TO_TUPLE = 
    Iso.of(
        point -> Tuple.of(point.x(), point.y()),
        tuple -> new Point(tuple._1(), tuple._2())
    );

Performance Notes

Isos are designed for efficient, lossless conversion:

  • Zero overhead composition: Multiple Iso compositions are fused into single operations
  • Lazy evaluation: Conversions only happen when needed
  • Type safety: All conversions are checked at compile time
  • Reusable: Isos can be stored and reused across your application

Best Practice: For frequently used conversions, create Isos as constants and test them:

public class DataIsos {
    public static final Iso<UserId, Long> USER_ID_LONG = 
        Iso.of(UserId::value, UserId::new);
    
    public static final Iso<Money, BigDecimal> MONEY_DECIMAL = 
        Iso.of(Money::amount, Money::new);
    
    // Test your isos
    static {
        testIsomorphism(USER_ID_LONG, new UserId(12345L));
        testIsomorphism(MONEY_DECIMAL, new Money(new BigDecimal("99.99")));
    }
  
    private static <A, B> void testIsomorphism(Iso<A, B> iso, A original) {
        B converted = iso.get(original);
        A roundTrip = iso.reverse().get(converted);
        if (!original.equals(roundTrip)) {
            throw new AssertionError("Iso failed round-trip test: " + original + " -> " + converted + " -> " + roundTrip);
        }
    }
}

Real-World Examples

1. API Data Transformation

// Internal model
public record Customer(String name, String email, LocalDate birthDate) {}

// External API model
public record CustomerDto(String fullName, String emailAddress, String birthDateString) {}

public class CustomerIsos {
    public static final Iso<Customer, CustomerDto> CUSTOMER_DTO = Iso.of(
        // Convert to DTO
        customer -> new CustomerDto(
            customer.name(),
            customer.email(),
            customer.birthDate().format(DateTimeFormatter.ISO_LOCAL_DATE)
        ),
        // Convert from DTO
        dto -> new Customer(
            dto.fullName(),
            dto.emailAddress(),
            LocalDate.parse(dto.birthDateString(), DateTimeFormatter.ISO_LOCAL_DATE)
        )
    );
  
    // Now any Customer lens can work with DTOs
    public static final Lens<CustomerDto, String> DTO_NAME = 
        CUSTOMER_DTO.reverse().andThen(CustomerLenses.name()).andThen(CUSTOMER_DTO);
}

2. Configuration Format Conversion

// Different configuration representations
public record DatabaseConfig(String host, int port, String database) {}
public record ConnectionString(String value) {}

public class ConfigIsos {
    public static final Iso<DatabaseConfig, ConnectionString> DB_CONNECTION = Iso.of(
        // To connection string
        config -> new ConnectionString(
            "jdbc:postgresql://" + config.host() + ":" + config.port() + "/" + config.database()
        ),
        // From connection string
        conn -> {
            // Simple parser for this example
            String url = conn.value();
            String[] parts = url.replace("jdbc:postgresql://", "").split("[:/]");
            return new DatabaseConfig(parts[0], Integer.parseInt(parts[1]), parts[2]);
        }
    );
  
    // Use with existing configuration lenses
    public static final Lens<DatabaseConfig, String> CONNECTION_STRING_HOST = 
        DB_CONNECTION.andThen(
            Lens.of(
                cs -> cs.value().split("//")[1].split(":")[0],
                (cs, host) -> new ConnectionString(cs.value().replaceFirst("//[^:]+:", "//" + host + ":"))
            )
        ).andThen(DB_CONNECTION.reverse());
}

3. Wrapper Type Integration

// Strongly-typed wrappers
public record ProductId(UUID value) {}
public record CategoryId(UUID value) {}

public class WrapperIsos {
    public static final Iso<ProductId, UUID> PRODUCT_ID_UUID = 
        Iso.of(ProductId::value, ProductId::new);
  
    public static final Iso<CategoryId, UUID> CATEGORY_ID_UUID = 
        Iso.of(CategoryId::value, CategoryId::new);
  
    // Use with any UUID-based operations
    public static String formatProductId(ProductId id) {
        return PRODUCT_ID_UUID
            .andThen(Iso.of(UUID::toString, UUID::fromString))
            .get(id);
    }
}

Complete, Runnable Example

This example puts all the steps together to show both direct conversion and composition.

public class IsoUsageExample {

    @GenerateLenses
    public record Point(int x, int y) {}

    @GenerateLenses
    public record Circle(Point centre, int radius) {}

    public static class Converters {
        @GenerateIsos
        public static Iso<Point, Tuple2<Integer, Integer>> pointToTuple() {
            return Iso.of(
                    point -> Tuple.of(point.x(), point.y()),
                    tuple -> new Point(tuple._1(), tuple._2()));
        }

        // Additional useful Isos
        public static final Iso<Point, String> POINT_STRING = Iso.of(
                point -> point.x() + "," + point.y(),
                str -> {
                    String[] parts = str.split(",");
                    return new Point(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
                }
        );
    }

    // Test helper
    private static <A, B> void testRoundTrip(Iso<A, B> iso, A original, String description) {
        B converted = iso.get(original);
        A roundTrip = iso.reverse().get(converted);
        System.out.println(description + ":");
        System.out.println("  Original:  " + original);
        System.out.println("  Converted: " + converted);
        System.out.println("  Round-trip: " + roundTrip);
        System.out.println("  Success: " + original.equals(roundTrip));
        System.out.println();
    }

    public static void main(String[] args) {
        // 1. Define a point and circle.
        var myPoint = new Point(10, 20);
        var myCircle = new Circle(myPoint, 5);

        System.out.println("=== ISO USAGE EXAMPLE ===");
        System.out.println("Original Point: " + myPoint);
        System.out.println("Original Circle: " + myCircle);
        System.out.println("------------------------------------------");

        // 2. Get the generated Iso.
        var pointToTupleIso = ConvertersIsos.pointToTuple;

        // --- SCENARIO 1: Direct conversions and round-trip testing ---
        System.out.println("--- Scenario 1: Direct Conversions ---");
        testRoundTrip(pointToTupleIso, myPoint, "Point to Tuple conversion");
        testRoundTrip(Converters.POINT_STRING, myPoint, "Point to String conversion");

        // --- SCENARIO 2: Using reverse() ---
        System.out.println("--- Scenario 2: Reverse Operations ---");
        var tupleToPointIso = pointToTupleIso.reverse();
        var myTuple = Tuple.of(30, 40);
        Point pointFromTuple = tupleToPointIso.get(myTuple);
        System.out.println("Tuple: " + myTuple + " -> Point: " + pointFromTuple);
        System.out.println();

        // --- SCENARIO 3: Composition with lenses ---
        System.out.println("--- Scenario 3: Composition with Lenses ---");

        // Create a lens manually that works with Point directly
        Lens<Point, Integer> pointToXLens = Lens.of(
                Point::x,
                (point, newX) -> new Point(newX, point.y())
        );

        // Use the lens
        Point movedPoint = pointToXLens.modify(x -> x + 5, myPoint);
        System.out.println("Original point: " + myPoint);
        System.out.println("After moving X by 5: " + movedPoint);
        System.out.println();

        // --- SCENARIO 4: Demonstrating Iso composition ---
        System.out.println("--- Scenario 4: Iso Composition ---");

        // Show how the Iso can be used to convert and work with tuples
        Tuple2<Integer, Integer> tupleRepresentation = pointToTupleIso.get(myPoint);
        System.out.println("Point as tuple: " + tupleRepresentation);

        // Modify the tuple using tuple operations
        Lens<Tuple2<Integer, Integer>, Integer> tupleFirstLens = Tuple2Lenses._1();
        Tuple2<Integer, Integer> modifiedTuple = tupleFirstLens.modify(x -> x * 2, tupleRepresentation);

        // Convert back to Point
        Point modifiedPoint = pointToTupleIso.reverse().get(modifiedTuple);
        System.out.println("Modified tuple: " + modifiedTuple);
        System.out.println("Back to point: " + modifiedPoint);
        System.out.println();

        // --- SCENARIO 5: String format conversions ---
        System.out.println("--- Scenario 5: String Format Conversions ---");

        String pointAsString = Converters.POINT_STRING.get(myPoint);
        System.out.println("Point as string: " + pointAsString);

        Point recoveredFromString = Converters.POINT_STRING.reverse().get(pointAsString);
        System.out.println("Recovered from string: " + recoveredFromString);
        System.out.println("Perfect round-trip: " + myPoint.equals(recoveredFromString));

        // --- SCENARIO 6: Working with Circle centre through Iso ---
        System.out.println("--- Scenario 6: Circle Centre Manipulation ---");

        // Get the centre as a tuple, modify it, and put it back
        Point originalCentre = myCircle.centre();
        Tuple2<Integer, Integer> centreAsTuple = pointToTupleIso.get(originalCentre);
        Tuple2<Integer, Integer> shiftedCentre = Tuple.of(centreAsTuple._1() + 10, centreAsTuple._2() + 10);
        Point newCentre = pointToTupleIso.reverse().get(shiftedCentre);
        Circle newCircle = CircleLenses.centre().set(newCentre, myCircle);

        System.out.println("Original circle: " + myCircle);
        System.out.println("Centre as tuple: " + centreAsTuple);
        System.out.println("Shifted centre tuple: " + shiftedCentre);
        System.out.println("New circle: " + newCircle);
    }

Expected Output:

=== ISO USAGE EXAMPLE ===
Original Point: Point[x=10, y=20]
Original Circle: Circle[centre=Point[x=10, y=20], radius=5]
------------------------------------------
--- Scenario 1: Direct Conversions ---
Point to Tuple conversion:
  Original:  Point[x=10, y=20]
  Converted: Tuple2[_1=10, _2=20]
  Round-trip: Point[x=10, y=20]
  Success: true

Point to String conversion:
  Original:  Point[x=10, y=20]
  Converted: 10,20
  Round-trip: Point[x=10, y=20]
  Success: true

--- Scenario 2: Reverse Operations ---
Tuple: Tuple2[_1=30, _2=40] -> Point: Point[x=30, y=40]

--- Scenario 3: Working with Different Representations ---
Original point: Point[x=10, y=20]
After moving X by 5: Point[x=15, y=20]

--- Scenario 4: Conversion Workflows ---
Point as tuple: Tuple2[_1=10, _2=20]
Modified tuple: Tuple2[_1=20, _2=20]
Back to point: Point[x=20, y=20]

--- Scenario 5: String Format Conversions ---
Point as string: 10,20
Recovered from string: Point[x=10, y=20]
Perfect round-trip: true

--- Scenario 6: Circle Centre Manipulation ---
Original circle: Circle[centre=Point[x=10, y=20], radius=5]
Centre as tuple: Tuple2[_1=10, _2=20]
Shifted centre tuple: Tuple2[_1=20, _2=30]
New circle: Circle[centre=Point[x=20, y=30], radius=5]

Why Isos are a Powerful Bridge

Lens, Prism, and Iso form a powerful trio for modelling any data operation. An Iso is the essential bridge that enables you to:

  • Work with the Best Representation: Convert data to the most suitable format for each operation, then convert back when needed.
  • Enable Library Integration: Adapt your internal data types to work seamlessly with external libraries without changing your core domain model.
  • Maintain Type Safety: All conversions are checked at compile time, eliminating runtime conversion errors.
  • Build Reusable Converters: Create tested, reusable conversion components that can be used throughout your application.

The step-by-step conversion approach shown in the examples is the most practical way to use Isos in real applications, providing clear, maintainable code that leverages the strengths of different data representations.


Previous:Prisms: Working with Sum Types Next:Traversals: Handling Bulk Updates