Using lumitrace to eliminate redundant type conversions in Ruby

Published: (March 7, 2026 at 11:12 AM EST)
3 min read
Source: Dev.to

Source: Dev.to

Using lumitrace to eliminate redundant type conversions in Ruby

When writing Ruby, it’s tempting to sprinkle .to_s, .to_i, and .to_sym calls “just in case.” Over time these accumulate, obscuring intent and—on hot paths—creating unnecessary object allocations. I used lumitrace to systematically find and remove these redundant conversions from RuVim, a Vim‑like editor written in Ruby.

What is lumitrace?

lumitrace records runtime values (types, counts, etc.) for each Ruby expression. With --collect-mode types it outputs JSON showing how many times each expression returned each type.

lumitrace --collect-mode types -j exec rake test

One command gives you a full type profile of your test suite.

What it revealed

For example, window.rb had this setter:

def cursor_x=(value)
  @cursor_x = value.to_i
end

lumitrace showed that value was 100 % Integer. All callers passed an Integer, so the .to_i was pure waste.

Similarly, keymap_manager.rb called mode.to_sym 31,341 times during the test run, but mode was always a Symbol. Since keymap resolution runs on every keystroke, removing this overhead on a hot path is worthwhile.

The process

  1. Run lumitrace --collect-mode types -j exec rake test to collect type data.
  2. Extract .to_s, .to_i, .to_sym patterns from the JSON and identify cases where the receiver was always the target type.
  3. Verify by checking all callers—only remove when confirmed safe.
  4. Run the test suite to confirm everything passes.

Result: ~50 redundant type conversions removed across 9 files.

ConversionApprox. removedKey locations
.to_s~25editor, completion_manager, dispatcher, global_commands
.to_i~15window, screen, text_metrics, app
.to_sym~15keymap_manager, editor, buffer, key_handler

From type inconsistency to better design

Beyond removing redundant conversions, type inconsistencies can reveal design issues. lumitrace records the frequency of each type per expression. A single type indicates stability; a mix suggests a potential problem.

The bang parameter in CommandInvocation illustrated this. lumitrace showed a mix of NilClass, FalseClass, and TrueClass:

def initialize(id:, argv: nil, kwargs: nil, count: nil, bang: nil, raw_keys: nil)
  @bang = !!bang
end

The default nil was coerced to a boolean with !!, but bang semantically represents “whether the Ex command has a ! suffix”—a flag that should be false by default, not “unspecified.” The fix:

def initialize(id:, argv: nil, kwargs: nil, count: nil, bang: false, raw_keys: nil)
  @bang = bang
end

This change wasn’t just about removing a conversion; the type profile highlighted inconsistent usage, prompting a cleaner design.

What I kept

Not every conversion should be removed. The following were intentionally preserved:

  • String slicing results (line[0...idx].to_s) — can return nil.
  • External input boundaries (effective_option(...).to_i) — user settings may be strings.
  • String‑to‑number parsing (m[1].to_i) — regex match results are strings.
  • APIs accepting both Symbol and String (spec_call.to_sym) — by design.

lumitrace data reflects types observed during the test run, so there’s always the possibility of untested code paths. Caller verification remains essential.

Takeaway

Having a type profile makes the question “is this .to_s necessary?” dramatically easier to answer. Without it, you’d need to trace callers through the codebase by hand. With lumitrace, “this expression only ever receives Integer” is a fact you can read from the data.

Ruby’s dynamic typing makes runtime type information especially valuable. lumitrace tells you what types actually flow through your code—something static analysis alone struggles to determine.

  • lumitrace:
  • RuVim:
  • Changes from this work:
0 views
Back to Blog

Related posts

Read more »