Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Writing DSL for DSL: Catch Code as It’s Born wi...

Writing DSL for DSL: Catch Code as It’s Born with TracePoint

Let’s dive into an unconventional use of Ruby’s TracePoint API, not for performance profiling, but for runtime introspection in metaprogramming-heavy Ruby code.

We’ll focus on real-world use case of writing DSLs for use with existing DSLs, exploring how TracePoint enables us to untangle messy results of metaprogramming techniques and do things like dynamic linking logic to DSL-generated methods.

Expect a blend of advanced Ruby techniques, practical insights, and a fresh perspective on a lesser-known Ruby API.

Avatar for Andrey Novikov

Andrey Novikov

April 24, 2026

More Decks by Andrey Novikov

Other Decks in Programming

Transcript

  1. Writing DSL for DSL Catch Code as It’s Born with

    the TracePoint API Andrey Novikov, Evil Martians RubyKaigi 2026 April 24, 2026
  2. About me Hi, I’m Andrey Back-end engineer at Evil Martians

    Writing Ruby, Go, and whatever Love open-source software Living in Japan Love to ride mopeds, motorcycles, and cars all over Japan
  3. Martian Open Source Yabeda: Ruby application instrumentation framework Lefthook: git

    hooks manager AnyCable: Polyglot replacement for ActionCable server PostCS S : A tool for transforming CS S with JavaS cript Imgproxy: Fast and secure standalone server for Logux: Client-server communication framework based on Overmind: Process manager for Procfile-based And many others at evilmartians.com/oss resizing and converting remote images Optimistic UI, CRDT, and log applications and tmux
  4. Typelizer Extracts data type information from serializers and models in

    your app Supports Alba, Alba, ActiveModel::Serializer, Oj::Serializer, Panko::Serializer Generates TypeScript types or OpenAPI schemas from it github.com/skryukov/typelizer
  5. One more martian Svyatoslav Kryukov (or Svyat) Back-end engineer at

    Evil Martians Author of the typelizer gem My virtual co-presenter today!
  6. Typelizer class PostResource < ApplicationResource include Typelizer::DSL attributes :id, :title,

    :body typelize :number attribute :rating { 42 } end Database Table Columns ActiveRecord Attributes Serializer Custom Fields rails typelizer:generate Post: type Post = { type: object id: number; properties: title: string; id: { type: number } body: string; title: { type: string } rating: number; body: { type: string } }; rating: { type: number } Typelizer TypeScript Typings OpenAPI Schema
  7. Typelizer’s challenge Serializers provide their own DSL to define fields

    Typelizer need to annotate these methods with its own DSL To allow re-defining types
  8. DSL-for-DSL Desired API is to annotate the serializer fields with

    type information: class PostResource < ApplicationResource attributes :id, :title, :body typelize :number attribute :rating { 42 } end This is second order DSL 🫣
  9. DSL-for-DSL challenge When you create a DSL that annotates a

    method, it works because def returns the method name: memoize def foo = computation memoize will get :foo and will know what to memoize.
  10. DSL-for-DSL challenge But we can’t expect that someone’s DSL method

    will return something useful to us, like API field name… irb(main):001:0> attribute :foo { 42 } # => ¯\_(ツ)_/¯ Even if it does accepting return value of a method call may not look nice in the code: typelize :number, comment: "something", \ attribute :foo { 42 }
  11. DSL-for-DSL challenge We can duplicate the method name: typelize foo:

    :number attribute :foo { 42 } But it is ugly! We want to specify just type: typelize :number attribute :foo { 42 }
  12. DSL-for-DSL challenge Of course we can just use prepend :

    module Typelizer::DSL::Hooks::Alba def attribute(name, *args, **kwargs, &block) Typelizer.consume_keyless_type(name) super(name, *args, **kwargs, &block) end def has_many(name, *args, **kwargs) Typelizer.consume_keyless_type(name) Typelizer.record_multi(name) super(name, *args, **kwargs, &block) end # … repeat for every DSL method in the serializer library we want to hook end
  13. Svyat wants to find a better approach That’s a lot

    of mundane repitition! If only there were a way to catch things without monkey-patching specific methods, so that anything newly added to serializers would just work…
  14. So we need a way to subscribe to events in

    Ruby runtime… TracePoint API (yes, again!)
  15. TracePoint API Allows subscribe to runtime events in the Typical

    use cases: code: Profiling / tracing slow code paths method calls / returns Coverage class / module definitions See day 1 talk from @anmarchenko exceptions Debuggers every line execution, etc. And get access to the context of the event: class instance, local variables, etc. TracePoint API Docs
  16. Quick anatomy trace = TracePoint.new(:call) do |tp| p [tp.event, tp.method_id,

    ...] end trace.enable # ... run something ... trace.disable tp.event : what happened tp.self : receiver tp.binding : local vars / self tp.defined_class : where method lives tp.path , tp.lineno : source location (when available) TracePoint API Docs
  17. The DSL-on-DSL challenge You want to write a DSL that

    integrates with someone else’s DSL: the “inner” DSL does something dynamically when user calls its methods our DSL needs to attach logic to those calls these methods are usually class or module methods, so they are not called on application runtime but rather on application load and those methods haven’t been called yet when our typelize method is called
  18. The trick Trace method calls during require of a serializer

    file Enable TracePoint only while loading target files: trace.enable require file trace.disable Make a special command that will load only serializers before they were loaded by the application autoloading. Watch for calls that happen on “typelized” classes: is this receiver relevant? is the called method “interesting”? if yes: extract context from binding
  19. Real code files.each do |file| trace = TracePoint.new(:call) do |tp|

    next unless typelized_class?(tp.self) serializer_plugin = build_scan_plugin_for(tp.self) next unless serializer_plugin if tp.callee_id.in?(serializer_plugin.methods_to_typelize) type, attrs = tp.self.keyless_type name = tp.binding.local_variable_get(:name) if tp.binding.local_variable_defined?(:name) tp.self.typelize(**serializer_plugin.typelize_method_transform(method: tp.callee_id, binding: tp.bindi tp.self.keyless_type = nil end end trace.enable require file trace.disable end Source: typelizer v0.6.0 generator.rb generator.rb:45-66
  20. Real code (fragment) type, attrs = tp.self.keyless_type # Saved from

    `typelize` method call if tp.binding.local_variable_defined?(:name) name = tp.binding.local_variable_get(:name) tp.self.typelize(name => [type, **attrs]) if name end tp.self.keyless_type = nil
  21. DSL with TracePoint API: pros and cons Pros More flexible

    Fun! Cons There is a performance overhead Harder to understand Need to rely on internal API of the target DSL A lot of logic in the handler TracePoint performance o…
  22. The main problem if tp.callee_id.in?(serializer_plugin.methods_to_typelize) We still need to keep

    track of all underlying methods Some of them have special logic E.g. has_many and belongs_to both are just aliases to resource in Alba, but has_many returns array of records, so we need to account for that.
  23. Disappointment The idea was that with TracePoint, you don’t have

    to touch specific methods — everything added to the libraries would work automatically. But in reality, sometimes the logic is hidden in the name itself! So if you have to keep record of all methods to build on anyway—then screw it, let’s just use prepend! Also it turned out to be faster and easier to understand!
  24. Typelizer goes to prepend module Typelizer::DSL::Hooks::Alba def attribute(name, *args, **kwargs,

    &block) Typelizer.consume_keyless_type(name) super end def has_many(name, *args, **kwargs) Typelizer.consume_keyless_type(name) Typelizer.record_multi(name) super end # … repeat for every DSL method in the serializer library we want to hook end Typelizer goes to prepend
  25. Tips for brave and courageous Rules of thumb: subscribe to

    one event when possible Most probably it will be :call only filter early ( next unless ... ) Handler will be called a lot, reduce performance overhead as much as possible enable TracePoint for a short time window Like only during require of a file, avoid enabling it in application runtime
  26. Thank you! @Envek @Envek Bye! github.com/Envek @skryukov @skryukov_dev github.com/skryu… @evilmartians

    @evilmartians 🌏 evilmartians.com evilmartians.com Our awesome blog: evilmartians.com/chronicles !