It’s October when I started writing this draft. Autumn has finally come to Japan. Coincidentally, the first time I came to Japan back in 2022 it was also Autumn. Leaves are turning yellow and red everywhere, temperature’s dropping, sunny mornings got replaced with cloudy ones and cold drizzles. It’s a bit confusing period where we were debating everyday on whether to wear jacket or not, or to turn on the heater or not. It was all so familiar yet so new.

At work, the past couple of months have been quite… let’s say, formative for me. Firstly, our company’s platform is expanding. Scaling up. We’re getting more and more clients. Invited to more events. And in the middle of all this, my role’s been quite simple: lead the 3D team to conceive solutions for BIM data analysis.

What is BIM, anyway?

Before moving forward with the content of this blog, I think I should address this first. Building Information Modeling (BIM) is a digital representation of a building’s physical and functional characteristics. Think of it as a 3D database where every wall, beam, door, window, pipe, and electrical outlet exists as a rich, data-laden object.

But, I feel that I need to underline the data-laden part. Because this is where the scale happens.

Now, picture a typical high-rise apartment building in Tokyo. Maybe 10 to 15 floors, each with 10 to 20 housing units. A single Revit file for such a project could easily contains hundreds of thousands of elements: structural columns and beams, thousands of wall segments, tens of thousands of doors and windows, complex MEP (mechanical, electrical, plumbing) systems snaking through every floor, fire safety equipment, fixtures, furniture… the list goes on.

Not just that, each element isn’t just geometry. Each of them could be data-rich objects with dozens of properties. Unlike traditional CAD drawings that are just lines and shapes, BIM elements carry properties: materials, costs, structural properties, acoustics, thermal characteristics, manufacturer and/or supplier details, and relationships to other components.

What kind of analysis do people usually do with BIM files though? Beyond managerial tasks (checking supplies, properties), we also do intensive calculations. Examples are many: computing spatial relationships (which elements are adjacent?), detecting clashes (does this pipe intersect that beam?), extracting quantities (how much concrete is needed?), and performing geometric calculations (what’s the total floor area per unit?). And we need to do this across hundreds of thousands of objects, often in real-time for interactive applications.

It’s this computational intensity, combined with the geometric complexity, that drew me back to Julia after nearly two years away.

Multiple Dispatch

Back when I was in Luxembourg, knee-deep in numerical works and simulation code, I’d used Julia mostly for creating experimental matrix solvers and typical numerical computing. Now, after months of wrestling with geometric calculations in other languages, writing the same verbose boilerplate over and over, I felt that familiar itch: What if I tried Julia again?

So I fired up the REPL. And within minutes, I was reminded why I was pulled to this language in the first place back then. And it was not just because of its speed (which rivals C for well-written code), but its elegance in abstraction. And at the heart of that is this thing called Multiple dispatch.

Wait. What Is Multiple Dispatch?

In a very simplified way, multiple dispatch means:

A function’s behavior is chosen based on the types of all its arguments, not just the first one.

If that’s a bit hard to imagine, think of this: in functional languages like Haskell or ML, you might use pattern matching on types, but it’s often limited to the structure of data rather than dynamic dispatch on multiple arguments. In procedural languages like Go or Python, you can overload functions, but it’s typically based on the first argument or requires manual type checking. For example, in Python:

def add(a, b):
    if isinstance(a, int) and isinstance(b, int):
        return a + b
    elif isinstance(a, float) and isinstance(b, float):
        return a + b
    elif isinstance(a, list) and isinstance(b, list):
        return a + b  # Concatenation
    else:
        raise TypeError("Unsupported types")

In Go, there’s no function overloading at all, so you need separate function names:

func addInts(a, b int) int {
    return a + b
}

func addFloats(a, b float64) float64 {
    return a + b
}

func addStrings(a, b string) string {
    return a + b
}

// To handle multiple types, you'd use interfaces (but lose type safety):
func add(a, b interface{}) interface{} {
    switch av := a.(type) {
    case int:
        if bv, ok := b.(int); ok {
            return av + bv
        }
    case float64:
        if bv, ok := b.(float64); ok {
            return av + bv
        }
    }
    return nil  // Silent failure—not ideal
}

In Julia

Now in Julia, the dispatch happens automatically based on all argument types at runtime, without explicit checks or separate function names:

add(a, b)

How? Well, Julia looks at both typeof(a) and typeof(b) to decide which implementation to run.

That’s it. That’s multiple dispatch in Julia. Really basic example of this are as follows:

# Points.jl
struct Point2D
    x::Float64
    y::Float64
end

struct Point3D
    x::Float64
    y::Float64
    z::Float64
end

distance(p::Point2D) = sqrt(p.x^2 + p.y^2)
distance(p::Point3D) = sqrt(p.x^2 + p.y^2 + p.z^2)

# Try it
p2 = Point2D(3.0, 4.0)
p3 = Point3D(1.0, 2.0, 2.0)

println(distance(p2))  # → 5.0
println(distance(p3))  # → 3.0

Two types, one function name. Zero inheritance. Zero writing stuff like distance2d or distance3d or making some generics or inheritances or writing some conditionals based on typeof.

Just clean, composable logic.

OK, but what does it do with BIM?

Let’s start with something simple, an analysis that people like me often do on BIM data: spatial analysis. During spatial analysis, we’ll be constantly adding points, scaling vectors, rotating meshes. In real life, simple questions like:

“List all bathrooms that are in the fifth floor and find if there’s any of them that is oversized.”

Will involve getting all the bathrooms’ instances, vertices, matrices–transforming them to their proper position–and then do intersecting check with the adjacent walls–which means getting all the walls’ instances, vertices, matrices–transforming them to their proper position before operating on them.

(Of course, that’s assuming we’ve successfully filtered those BIM elements so we only have those on the fifth floor, but that’s a whole other blog post)

So how does multiple dispatch help? Again, let’s go with some examples.

Example 1: Basic Geometric Operations

With multiple dispatch, adding more implementations for base operators like + becomes trivial and type-safe.

import Base.+ # Importing base plus symbol function

# Add implementations for points in 2D and 3D
+(a::Point2D, b::Point2D) = Point2D(a.x + b.x, a.y + b.y)
+(a::Point3D, b::Point3D) = Point3D(a.x + b.x, a.y + b.y, a.z + b.z)

# Even for vectors!
struct Vec2D
    dx::Float64
    dy::Float64
end

+(p::Point2D, v::Vec2D) = Point2D(p.x + v.dx, p.y + v.dy)
+(v::Vec2D, p::Point2D) = p + v  # commutative convenience

With those new implementations in place, we can now write natural expressions:

origin = Point2D(0.0, 0.0)
offset = Vec2D(10.0, 5.0)
new_point = origin + offset  # → Point2D(10.0, 5.0)

No wrapper classes. No method overloading via long function names like addPointToPoint2D. Just +, dispatched correctly based on what you feed it.

And because Julia compiles specialized machine code for each method, this isn’t just pretty to look at: it is fast. Critical when you’re processing thousands of building elements. Hundreds of thousands of points, vectors. Maybe millions of them.

Example 2: Bounding Box Calculations

Next example is bounding box calculations. In BIM, we constantly need to compute bounding boxes for spatial queries. Multiple dispatch makes this elegant across different geometric primitives:

# Different BIM element types
struct Wall
    start_point::Point3D
    end_point::Point3D
    height::Float64
    thickness::Float64
end

struct Door
    center::Point3D
    width::Float64
    height::Float64
    rotation::Float64
end

struct Room
    corners::Vector{Point3D}
    floor_level::Float64
    ceiling_level::Float64
end

# Bounding box type
struct BoundingBox
    min::Point3D
    max::Point3D
end

# Multiple dispatch: compute bounding box for different element types
function bounding_box(w::Wall)
    x_vals = [w.start_point.x, w.end_point.x]
    y_vals = [w.start_point.y, w.end_point.y]
    z_vals = [w.start_point.z, w.start_point.z + w.height]
    
    BoundingBox(
        Point3D(minimum(x_vals), minimum(y_vals), minimum(z_vals)),
        Point3D(maximum(x_vals), maximum(y_vals), maximum(z_vals))
    )
end

function bounding_box(d::Door)
    # Simplified: assumes door is axis-aligned (ignoring rotation for this example)
    half_width = d.width / 2
    half_height = d.height / 2
    
    BoundingBox(
        Point3D(d.center.x - half_width, d.center.y - half_height, d.center.z),
        Point3D(d.center.x + half_width, d.center.y + half_height, d.center.z + d.height)
    )
end

function bounding_box(r::Room)
    x_coords = [p.x for p in r.corners]
    y_coords = [p.y for p in r.corners]
    
    BoundingBox(
        Point3D(minimum(x_coords), minimum(y_coords), r.floor_level),
        Point3D(maximum(x_coords), maximum(y_coords), r.ceiling_level)
    )
end

# Now we can uniformly query bounding boxes:
wall = Wall(Point3D(0,0,0), Point3D(10,0,0), 3.0, 0.2)
door = Door(Point3D(5,0,1), 0.9, 2.1, 0.0)
room = Room([Point3D(0,0,0), Point3D(10,0,0), Point3D(10,8,0), Point3D(0,8,0)], 0.0, 3.0)

bbox_wall = bounding_box(wall)
bbox_door = bounding_box(door)
bbox_room = bounding_box(room)

One function name, three different implementations. No inheritance hierarchy needed.

Example 3: Intersection Tests

For clash detection and spatial queries, we need intersection tests between different element types. Watch how dispatch chains elegantly:

# Check if two bounding boxes intersect
function intersects(a::BoundingBox, b::BoundingBox)
    return (a.min.x <= b.max.x && a.max.x >= b.min.x) &&
           (a.min.y <= b.max.y && a.max.y >= b.min.y) &&
           (a.min.z <= b.max.z && a.max.z >= b.min.z)
end

# Check if a point is inside a bounding box
function intersects(p::Point3D, box::BoundingBox)
    return (box.min.x <= p.x <= box.max.x) &&
           (box.min.y <= p.y <= box.max.y) &&
           (box.min.z <= p.z <= box.max.z)
end

# Check if a door intersects with a wall (converts to bounding box comparison)
function intersects(d::Door, w::Wall)
    door_box = bounding_box(d)
    wall_box = bounding_box(w)
    return intersects(door_box, wall_box)  # Dispatch to BoundingBox-BoundingBox method!
end

# Check if a room contains a door (checks if door center is in room)
function intersects(r::Room, d::Door)
    room_box = bounding_box(r)
    door_center = d.center
    return intersects(door_center, room_box)  # Dispatch to Point3D-BoundingBox method!
end

# Usage is beautifully uniform:
if intersects(door, wall)
    println("Door intersects with wall - possible opening")
end

if intersects(room, door)
    println("Door is within room bounds")
end

Same function name, but multiple implementations. Notice how the Door-Wall check composes the BoundingBox-BoundingBox check? That’s called dispatch chaining, and I honestly think it’s really hard to achieve this elegantly in languages without multiple dispatch.

Example 4: Transformation Matrices

BIM elements often need transformation in 3D space. For instance, placing prefabricated wall sections, rotating HVAC components to fit constraints, or scaling temporary structures. Multiple dispatch handles different transformation types elegantly by letting the type system determine which transformation logic applies:

# Different transformation types
struct Translation
    offset::Point3D
end

struct Rotation
    angle::Float64  # radians
    axis::Point3D   # rotation axis
end

struct Scale
    factor::Float64
end

# Apply transformations to points (fundamental building block)
function transform(p::Point3D, t::Translation)
    Point3D(p.x + t.offset.x, p.y + t.offset.y, p.z + t.offset.z)
end

function transform(p::Point3D, r::Rotation)
    # Full 3D rotation using Rodrigues' rotation formula
    # Rotates point p by angle r.angle around axis r.axis
    
    # Normalize the rotation axis
    axis = r.axis
    axis_length = sqrt(axis.x^2 + axis.y^2 + axis.z^2)
    if axis_length == 0
        return p  # No rotation if axis is zero
    end
    k_x = axis.x / axis_length
    k_y = axis.y / axis_length
    k_z = axis.z / axis_length
    
    # Rodrigues' formula: v_rot = v*cos(θ) + (k × v)*sin(θ) + k*(k·v)*(1-cos(θ))
    cos_θ = cos(r.angle)
    sin_θ = sin(r.angle)
    one_minus_cos = 1.0 - cos_θ
    
    # Dot product: k · v
    k_dot_v = k_x * p.x + k_y * p.y + k_z * p.z
    
    # Cross product: k × v
    cross_x = k_y * p.z - k_z * p.y
    cross_y = k_z * p.x - k_x * p.z
    cross_z = k_x * p.y - k_y * p.x
    
    # Apply Rodrigues' formula
    Point3D(
        p.x * cos_θ + cross_x * sin_θ + k_x * k_dot_v * one_minus_cos,
        p.y * cos_θ + cross_y * sin_θ + k_y * k_dot_v * one_minus_cos,
        p.z * cos_θ + cross_z * sin_θ + k_z * k_dot_v * one_minus_cos
    )
end

function transform(p::Point3D, s::Scale)
    Point3D(p.x * s.factor, p.y * s.factor, p.z * s.factor)
end

# Transform entire walls
function transform(w::Wall, t::Translation)
    Wall(
        transform(w.start_point, t),
        transform(w.end_point, t),
        w.height,
        w.thickness
    )
end

function transform(w::Wall, s::Scale)
    Wall(
        transform(w.start_point, s),
        transform(w.end_point, s),
        w.height * s.factor,
        w.thickness * s.factor
    )
end

function transform(w::Wall, r::Rotation)
    Wall(
        transform(w.start_point, r),
        transform(w.end_point, r),
        w.height,
        w.thickness
    )
end

# Transform doors
function transform(d::Door, t::Translation)
    Door(
        transform(d.center, t),
        d.width,
        d.height,
        d.rotation
    )
end

# Chain transformations naturally:
point = Point3D(1.0, 0.0, 0.0)
translated = transform(point, Translation(Point3D(5.0, 5.0, 0.0)))
rotated = transform(translated, Rotation(π/4, Point3D(0,0,1)))
scaled = transform(rotated, Scale(2.0))

Example 5: Area and Volume Calculations

Different elements need different calculation methods:

# Helper: distance between two points
function distance(p1::Point3D, p2::Point3D)
    sqrt((p1.x - p2.x)^2 + (p1.y - p2.y)^2 + (p1.z - p2.z)^2)
end

# Calculate area/volume for different types
function volume(w::Wall)
    length = distance(w.start_point, w.end_point)
    length * w.height * w.thickness
end

function volume(r::Room)
    # Simplified: assumes rectangular room
    corners = r.corners
    length = distance(corners[1], corners[2])
    width = distance(corners[2], corners[3])
    height = r.ceiling_level - r.floor_level
    length * width * height
end

function area(r::Room)
    # Floor area
    corners = r.corners
    length = distance(corners[1], corners[2])
    width = distance(corners[2], corners[3])
    length * width
end

function area(w::Wall)
    # Wall surface area
    length = distance(w.start_point, w.end_point)
    length * w.height
end

# Now we can write generic analysis functions:
function total_volume(elements::Vector)
    sum(volume(elem) for elem in elements)
end

# Works with any collection of elements that have a volume method!
# Create some example walls
bathroom_walls = [
    Wall(Point3D(0,0,0), Point3D(3,0,0), 2.5, 0.15),
    Wall(Point3D(3,0,0), Point3D(3,2,0), 2.5, 0.15),
    Wall(Point3D(3,2,0), Point3D(0,2,0), 2.5, 0.15),
    Wall(Point3D(0,2,0), Point3D(0,0,0), 2.5, 0.15)
]
bathroom_volume = total_volume(bathroom_walls)

Example 6: Real-World Application: Finding Oversized Bathrooms

Alright, now that we have done some examples on how multiple dispatch can be used in BIM operations, do you still remember that query from earlier? Well, we can now solve it as follows:

function is_oversized_bathroom(room::Room, threshold_area::Float64)
    room_area = area(room)
    return room_area > threshold_area
end

function analyze_floor(rooms::Vector{Room}, floor_level::Float64, area_threshold::Float64)
    # Filter rooms on the specified floor
    floor_rooms = filter(r -> r.floor_level  floor_level, rooms)
    
    # Find oversized ones (architectural finding: bathrooms shouldn't exceed threshold)
    oversized = filter(r -> is_oversized_bathroom(r, area_threshold), floor_rooms)
    
    # Report results with geometric context
    for room in oversized
        bbox = bounding_box(room)
        room_area = area(room)
        println("⚠ Oversized room: $(room_area) m² at position ($(bbox.min.x), $(bbox.min.y))")
    end
    
    return oversized
end

# Usage:
all_rooms = [
    Room([Point3D(0,0,0), Point3D(3,0,0), Point3D(3,2,0), Point3D(0,2,0)], 5.0, 8.0),  # Floor 5
    Room([Point3D(0,0,0), Point3D(4,0,0), Point3D(4,3,0), Point3D(0,3,0)], 5.0, 8.0),  # Floor 5, larger
    Room([Point3D(0,0,0), Point3D(2,0,0), Point3D(2,1.5,0), Point3D(0,1.5,0)], 6.0, 9.0) # Floor 6
]

fifth_floor_bathrooms = analyze_floor(all_rooms, 5.0, 8.0)  # 8m² threshold

All of these functions–bounding boxes, intersections, transformations, calculations–they use the same names across wildly different types. No inheritance pyramids. No visitor patterns. No type switches. Just methods dispatching on types.

Example 7: Clearance Checks

In BIM analysis, elements don’t exist in isolation. A door needs to know if it fits in a wall. A pipe needs to avoid beams. A room needs to contain furniture. These are cross-type interactions, and multiple dispatch handles them beautifully.

Consider checking if there’s enough clearance between different element types:

# Additional element types for cross-type interactions
struct Pipe
    start_point::Point3D
    end_point::Point3D
    diameter::Float64
end

struct Beam
    start_point::Point3D
    end_point::Point3D
    cross_section_width::Float64
    cross_section_height::Float64
end

struct Furniture
    center::Point3D
    width::Float64
    depth::Float64
    height::Float64
end

# Extend bounding_box for new types
function bounding_box(p::Pipe)
    radius = p.diameter / 2
    BoundingBox(
        Point3D(min(p.start_point.x, p.end_point.x) - radius, 
                min(p.start_point.y, p.end_point.y) - radius,
                min(p.start_point.z, p.end_point.z) - radius),
        Point3D(max(p.start_point.x, p.end_point.x) + radius,
                max(p.start_point.y, p.end_point.y) + radius,
                max(p.start_point.z, p.end_point.z) + radius)
    )
end

function bounding_box(b::Beam)
    BoundingBox(
        Point3D(min(b.start_point.x, b.end_point.x) - b.cross_section_width/2,
                min(b.start_point.y, b.end_point.y) - b.cross_section_height/2,
                min(b.start_point.z, b.end_point.z) - b.cross_section_height/2),
        Point3D(max(b.start_point.x, b.end_point.x) + b.cross_section_width/2,
                max(b.start_point.y, b.end_point.y) + b.cross_section_height/2,
                max(b.start_point.z, b.end_point.z) + b.cross_section_height/2)
    )
end

function bounding_box(f::Furniture)
    BoundingBox(
        Point3D(f.center.x - f.width/2, f.center.y - f.depth/2, f.center.z),
        Point3D(f.center.x + f.width/2, f.center.y + f.depth/2, f.center.z + f.height)
    )
end

# Helper: minimum distance between two bounding boxes
function minimum_distance(box1::BoundingBox, box2::BoundingBox)
    # Returns 0 if overlapping, otherwise minimum separation
    if intersects(box1, box2)
        return 0.0
    end
    
    # Calculate minimum separation distance
    dx = max(box1.min.x - box2.max.x, box2.min.x - box1.max.x, 0)
    dy = max(box1.min.y - box2.max.y, box2.min.y - box1.max.y, 0)
    dz = max(box1.min.z - box2.max.z, box2.min.z - box1.max.z, 0)
    
    sqrt(dx^2 + dy^2 + dz^2)
end

# Different clearance rules for different element combinations

# Pipe-to-beam clearance (stricter for structural elements)
function check_clearance(pipe::Pipe, beam::Beam, min_distance::Float64 = 0.15)
    pipe_box = bounding_box(pipe)
    beam_box = bounding_box(beam)
    
    # Calculate minimum distance between bounding boxes
    dist = minimum_distance(pipe_box, beam_box)
    return dist >= min_distance, dist
end

# Door-to-wall clearance (different logic entirely)
function check_clearance(door::Door, wall::Wall, min_distance::Float64 = 0.05)
    # Doors should be *in* walls, so we check if door is properly embedded
    door_box = bounding_box(door)
    wall_box = bounding_box(wall)
    
    # Check if door is within wall thickness tolerance
    is_embedded = intersects(door_box, wall_box)
    if is_embedded
        # Check if door doesn't extend beyond wall height
        extends_beyond = door_box.max.z > wall_box.max.z
        return !extends_beyond, 0.0
    else
        return false, minimum_distance(door_box, wall_box)
    end
end

# Furniture-to-door clearance (accessibility requirements)
function check_clearance(furniture::Furniture, door::Door, min_distance::Float64 = 0.80)
    # Need wider clearance for accessibility
    furn_box = bounding_box(furniture)
    door_box = bounding_box(door)
    
    dist = minimum_distance(furn_box, door_box)
    return dist >= min_distance, dist
end

# Now use it:
hvac_pipe = Pipe(Point3D(0,0,2.0), Point3D(5,0,2.0), 0.1)
structural_beam = Beam(Point3D(0,0,3.0), Point3D(5,0,3.0), 0.3, 0.5)
entry_door = Door(Point3D(1.5,0,0), 0.9, 2.1, 0.0)
exterior_wall = Wall(Point3D(0,0,0), Point3D(10,0,0), 3.0, 0.2)
desk = Furniture(Point3D(2,1,0), 1.2, 0.6, 0.75)
office_door = Door(Point3D(3,1.5,0), 0.9, 2.1, 0.0)

pipe_clear, pipe_dist = check_clearance(hvac_pipe, structural_beam)
door_clear, door_dist = check_clearance(entry_door, exterior_wall)
furniture_clear, furn_dist = check_clearance(desk, office_door, 1.0)  # Override default

Same function name. Three completely different implementations. Each pair of types gets its own logic, with its own default parameters, its own calculation method, and its own return semantics.

Try writing that in Go or Python without either:

  • A massive switch statement on type combinations
  • A complex visitor pattern
  • Losing type safety entirely

The Golang Reality Check

After months of writing Go for infrastructure tooling, coming back to Julia felt like breathing fresh air.

Now, I love Go. Its simplicity, its concurrency model (I haven’t found other programming language that implements async as beautifully as Go so far), its tooling, they’re all excellent for building reliable systems. But when it comes to the kind of polymorphic behavior we need in BIM analysis? Go makes us work hard.

Let’s take that clearance check example. Remember how clean it was in Julia? Here’s what the Go equivalent looks like:

// Separate functions for every type combination
func CheckClearancePipeBeam(pipe Pipe, beam Beam, minDistance float64) (bool, float64) {
    pipeBox := BoundingBoxFromPipe(pipe)
    beamBox := BoundingBoxFromBeam(beam)
    dist := MinimumDistance(pipeBox, beamBox)
    return dist >= minDistance, dist
}

func CheckClearanceDoorWall(door Door, wall Wall, minDistance float64) (bool, float64) {
    doorBox := BoundingBoxFromDoor(door)
    wallBox := BoundingBoxFromWall(wall)
    // ... different logic here
}

func CheckClearanceFurnitureDoor(furniture Furniture, door Door, minDistance float64) (bool, float64) {
    // ... and different logic here
}

// And you'd need even more for different orderings:
func CheckClearanceBeamPipe(beam Beam, pipe Pipe, minDistance float64) (bool, float64) {
    // Wait, is this different from PipeBeam? Do I need this?
}

Already verbose. But it gets worse when we want to handle them generically.

We could use interfaces:

type BIMElement interface {
    BoundingBox() BoundingBox
}

func CheckClearance(elem1, elem2 BIMElement, minDistance float64) (bool, float64) {
    // But now you've lost all the specific logic!
    // Every element just becomes a bounding box
    box1 := elem1.BoundingBox()
    box2 := elem2.BoundingBox()
    
    // What about door-in-wall logic? What about material checks?
    // You'd need type switches:
    switch e1 := elem1.(type) {
    case Door:
        switch e2 := elem2.(type) {
        case Wall:
            // Door-wall specific logic
        case Room:
            // Door-room specific logic
        }
    case Pipe:
        switch e2 := elem2.(type) {
        case Beam:
            // Pipe-beam specific logic
        case Wall:
            // Pipe-wall specific logic
        }
    // ... and so on for every combination
    }
}

This explodes combinatorially. With 5 element types, we’re looking at 25 potential combinations. With 10 types? 100 combinations. (My math probably wrong on the exact number; CMIIW). And every single one needs its own case in a nested switch statement.

Or you go full OOP and do:

type Clearable interface {
    CheckClearanceWith(other Clearable, minDistance float64) (bool, float64)
}

func (p Pipe) CheckClearanceWith(other Clearable, minDistance float64) (bool, float64) {
    // Now you're back to type assertions anyway
    switch elem := other.(type) {
    case Beam:
        // Pipe-beam logic
    case Wall:
        // Pipe-wall logic
    default:
        // Generic fallback
    }
}

Still type switches. Still combinatorial complexity. And you’ve lost type safety—the compiler won’t tell you if you forgot to handle a specific pair.

Compare this to Julia:

check_clearance(pipe::Pipe, beam::Beam, min_distance::Float64 = 0.15) = ...
check_clearance(door::Door, wall::Wall, min_distance::Float64 = 0.05) = ...

Two lines. Type-safe. Nice and elegant.

Go being very simple is a big advantage for some contexts. But for problems requiring rich type interactions, like BIM analysis, game engines, scientific computing, it forces you into verbose, error-prone patterns. At the same time, Julia just… gets it.


So… Why Isn’t Everyone Doing This?

Honestly, I don’t know. Multiple dispatch feels like one of programming’s best-kept secrets. Me not being a computer science graduates honestly probably hindered me from getting exposed to this earlier. But reading the history a little bit more, multiple dispatch is clearly not really a secret. Dispatch as a concept itself dated way back then. And Julia programming language made the choice of making multiple dispatch as its primary paradigm.

The language was created in 2012 by four researchers at MIT: Jeff Bezanson, Stefan Karpinski, Viral Shah, and Alan Edelman (a professor of applied mathematics). They were frustrated with the “two-language problem”, which is writing high-level code in MATLAB or Python for prototyping, then rewrite performance-critical parts in C or Fortran. They wanted one language that combined the ease of MATLAB with the speed of C.

But more importantly, they wanted a language that thinks like mathematicians think.

In mathematics, you don’t write add_real_numbers(a, b) and add_complex_numbers(c, d). You write $f(x)$ and the context determines what $f$ means. Addition works on reals, complexes, matrices, polynomials. The operation is conceptually the same, but the implementation differs based on the operands.

This is exactly what multiple dispatch enables. The function name represents the mathematical concept. The types determine the concrete behavior. It’s declarative. It’s compositional. It’s how we naturally think about mathematical operations.

And that’s why Julia feels so natural for scientific computing. It mirrors mathematical notation and thought processes in a way that many other languages simply don’t. When you write:

A + B

You’re not going to think “is A an object with an add method?” Instead, you’ll be thinking “what are A and B, and how should addition work for them?” That’s multiple dispatch.

Disclaimer: I’m not a mathematician. But somehow I spent years in academia (probably too long, but that’s another blog post!) surrounded by people who think in equations and abstractions. And maybe that’s why Julia resonates with me so deeply.

Yet most mainstream languages stick to single dispatch or procedural overloading. Why?

What’s Next?

Honestly? I’m now seriously considering integrating Julia into our BIM pipeline–especially for geometry-heavy preprocessing tasks. The speed is there, the expressiveness is there, and the interop story is improving.

The question, of course, is how. We can’t just insert whole new code in an already scaling, established platform. Maybe lambda function? Separated microservices? Undecided.

And that’s actually makes me wonder: should I also try to make a REST API server with Julia? Maybe in the next post.

By the way, the whole examples above can be found in this notebook. Let me know if you have any questions (leave some comments here or reach me on LinkedIn).

Thank you for reading!