Slide 1

Slide 1 text

Writing DSL for DSL Catch Code as It’s Born with the TracePoint API Andrey Novikov, Evil Martians RubyKaigi 2026 April 24, 2026

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

邪悪な 火星人 ? evilmartians.com evilmartians.jp 🏯

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

One more martian Svyatoslav Kryukov (or Svyat) Back-end engineer at Evil Martians Author of the typelizer gem My virtual co-presenter today!

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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 🫣

Slide 10

Slide 10 text

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.

Slide 11

Slide 11 text

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 }

Slide 12

Slide 12 text

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 }

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

T HE END Let’s get some lunch without queues!

Slide 15

Slide 15 text

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…

Slide 16

Slide 16 text

So we need a way to subscribe to events in Ruby runtime… TracePoint API (yes, again!)

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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…

Slide 24

Slide 24 text

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.

Slide 25

Slide 25 text

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!

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

Can you use TracePoint API to build a DSL? Absolutely YES!

Slide 28

Slide 28 text

But should you use TracePoint API to build a DSL? Probably not!

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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 !