The error from the following code will be ambiguous (we do not know, whether the height or width is missing):

with {:ok, width}  <- Map.fetch(opts, :width),
     {:ok, height} <- Map.fetch(opts, :height)
do
  {:ok, width * height}
else
  :error -> {:error, :wrong_data}
end

By wrapping our return values inside tuples, with distinct atoms as first values, we can pattern match on specific error values inside the else block:

with {:width, {:ok, width}}   <- {:width, Map.fetch(opts, :width)},
     {:height, {:ok, height}} <- {:height, Map.fetch(opts, :height)}
do
  {:ok, width * height}
else
  {:width, :error} -> {:error, :missing_width}
  {:height, :error} -> {:error, :missing_height}
end

A more complicated example:

with {:user, {:ok, user}} <- {:user, Users.get(user_id)},
     {:game, {:ok, game}} <- {:game, Games.get(game_id)},
     {:full, false}       <- {:full, Game.is_full?(game)},
     {:started, false}    <- {:started, Game.is_started?(game)},
     {:allowed, true}     <- {:allowed, User.has_permission?(user, game)}
do
  Game.add_user(game, user)
else
  {:user, :not_found} -> {:error, "User not found"}
  {:game, :not_found} -> {:error, "Game not found"}
  {:full, true}       -> {:error, "Game is full"}
  {:started, true}    -> {:error, "Game has already started"}
  {:allowed, false}   -> {:error, "User is not allowed to join this game"}
end

Source