Use `Integer.parse/1` for params, not `String.to_integer/1`

almirsarajcic

almirsarajcic

2 hours ago

0 comments

Phoenix does zero automatic type coercion. Every value in params arrives as a string — or not at all. Reaching for String.to_integer/1 on user-supplied params is a crash waiting to happen in two different ways.

# ❌ Crashes on bad input AND on missing keys
def handle_event("paginate", params, socket) do
  page = String.to_integer(params["page"])  # raises ArgumentError on "abc"
                                             # raises FunctionClauseError on nil
  {:noreply, assign(socket, page: page)}
end

# ✅ Handles both gracefully
def handle_event("paginate", params, socket) do
  case parse_integer(params["page"]) do
    {:ok, page} -> {:noreply, assign(socket, page: page)}
    :error -> {:noreply, socket}
  end
end

Plug.Conn.Query decodes every query parameter as a string. When a key is absent entirely, params["page"] returns nil, and String.to_integer(nil) raises ** (FunctionClauseError) no function clause matching in String.to_integer/1 — a different crash from the ArgumentError you’d get on "abc". Both are unhandled exceptions in production.

Integer.parse/1 is the right tool, but it has a gotcha you need to account for:

Integer.parse("123")     # {123, ""}    — success
Integer.parse("-42")     # {-42, ""}    — negative numbers work
Integer.parse("abc")     # :error
Integer.parse("")        # :error
Integer.parse(" 123")    # :error       — leading whitespace fails
Integer.parse("123abc")  # {123, "abc"} — partial parse, NOT :error

That last case is the one that bites people. "123abc" does not return :error — it returns a tuple with a non-empty remainder. Matching only on {n, ""} rejects it correctly:

@spec parse_integer(String.t() | nil) :: {:ok, integer()} | :error
def parse_integer(value) when is_binary(value) do
  case Integer.parse(value) do
    {n, ""} -> {:ok, n}
    _ -> :error
  end
end

def parse_integer(_), do: :error

The second clause catches nil and any other non-string value without an explicit nil check. The _ branch in the case handles both :error and partial parses like {123, "abc"} in one shot — no need to enumerate them separately.

A few things worth keeping in mind:

  • Leading whitespace fails silentlyInteger.parse(" 123") returns :error, so if you’re dealing with form inputs that might have whitespace, String.trim/1 before parsing is appropriate
  • String.to_integer/1 is not always wrong — it’s fine when the value is guaranteed to be a well-formed integer string, such as an ID coming from your own database query or a compiled route parameter. It’s user-supplied params where it’s dangerous
  • Integer.parse/1 accepts any radixInteger.parse("ff", 16) returns {255, ""}, which is handy for things like color parsing

Comments (0)

Sign in with GitHub to join the discussion