Skip to content

stevegeek/typed_view_model

Repository files navigation

typed_view_model

Typed, immutable Rails view-model objects on top of Literal::Data, with composable presentation traits.


What it provides

Typed, frozen view-model objects on top of Literal::Data for Rails apps. Adds a helper-mixin DSL (helpers :i18n, :path, …), a Trait module system for sharing presentation logic across view-models, fragment-cache key generation (WithCacheKey), and a generated host-app shim that makes view-models usable from ActiveJob. Instances are unit-testable in isolation — no controller, request, or view context required.


Installation

# Gemfile
gem "typed_view_model"
bundle install

Requires Rails ≥ 8.0 and Ruby ≥ 3.2. Hard runtime dependencies: literal, activesupport, actionpack.

After bundling, scaffold the host-app integration:

bin/rails generate typed_view_model:install

See Rendering view models from background jobs to wire up job-side rendering.


30-second quickstart

# app/views_models/product_card_view_model.rb
class ProductCardViewModel < ApplicationViewModel
  helpers :i18n, :path, :format

  prop :product, ::Product

  def title
    product.name
  end

  def detail_path
    product_path(product)
  end

  def humanized_price
    number_to_currency(product.price)
  end
end
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
  def show
    @card = ProductCardViewModel.new(product: Product.find(params[:id]))
  end
end
<%# app/views/products/show.html.erb %>
<article>
  <h3><%= link_to @card.title, @card.detail_path %></h3>
  <p><%= @card.humanized_price %></p>
</article>

Instantiate in the controller (or in a parent VM, or pass to a ViewComponent). Instances are frozen and value-equal on attributes; unit-test them without a request or view context.


Core concepts

TypedViewModel::Base

A subclass of Literal::Data. Subclass it (typically via your generated ApplicationViewModel) and declare props with prop. All Literal::Data semantics apply: typed kwarg initializer, frozen instances, value equality, ? predicate methods for _Boolean props.

class MyViewModel < ApplicationViewModel
  prop :user, ::User
  prop :compact, _Boolean, default: false
end

For the type system itself (_Boolean, _Array(T), _Nilable(T), etc.) refer to the Literal documentation.

The helpers(*names) DSL

Class-level. For each name passed, looks up "#{Name}Helpers" in each module in TypedViewModel.helper_namespaces (in order, first match wins, no inheritance lookup) and includes the matching module. Raises ArgumentError if no namespace defines a matching constant.

class MyViewModel < ApplicationViewModel
  helpers :i18n, :path, :format, :url
end

helpers :name includes a helper module's methods directly into the view model class. The methods become first-class on the receiver: declare what you need, then call it.

class ProductCardViewModel < ApplicationViewModel
  helpers :i18n, :format

  prop :product, ::Product

  def humanized_price
    number_to_currency(product.price)
  end

  def label
    t("products.card.label")
  end
end

Per-request view-context stash

TypedViewModel.current_helpers is a per-request slot for the host's Rails view context. The stash backend is fiber-safe on Rails 7+ (ActiveSupport::IsolatedExecutionState) and Thread.current otherwise.

Include the shipped controller concern in your ApplicationController to populate it automatically:

class ApplicationController < ActionController::Base
  include TypedViewModel::ControllerHelpers
end

ControllerHelpers installs an around_action that wraps each request in TypedViewModel.with_current_helpers(view_context) { … }, restoring the previous value on exit (including on raise).

If you need to set the stash manually (e.g. inside a job, or from a non-controller code path):

TypedViewModel.with_current_helpers(some_view_context) do
  # view models constructed inside the block can call their helper-DSL methods
end

# or unscoped:
TypedViewModel.current_helpers = some_view_context

Helpers

Seven opt-in helper modules ship with the gem. Each is included via helpers :name.

:i18n

I18nHelpers. Forwards t / translate and l / localize to I18n.

helpers :i18n
# ...
t("shopping.cart.empty") # => "Your cart is empty"

:format

FormatHelpers. Number and date formatting: number_to_currency, number_with_precision, number_to_percentage, number_with_delimiter, number_to_human, number_to_human_size, number_to_phone, distance_of_time_in_words, time_ago_in_words.

helpers :format
# ...
number_to_currency(product.price_cents / 100.0)

:path

PathHelpers. url_helpers returns Rails.application.routes.url_helpers. Any *_path / *_url method that exists on it is forwarded via method_missing (with respond_to_missing?).

helpers :path
# ...
product_path(product)

:url

UrlHelpers. Provides url_for(source) via Rails.application.routes.url_helpers.url_for. Useful for ActiveStorage attachments without dragging in the full path-helper module.

helpers :url
# ...
url_for(product.image_attachment)

:tag

TagHelpers. Forwards content_tag, sanitize, safe_join, tag to the per-request view context. Raises RuntimeError if current_helpers is unset.

helpers :tag
# ...
content_tag(:p, "hello")
sanitize(rich_html)

:text

TextHelpers. Forwards truncate, pluralize, simple_format, excerpt, highlight, word_wrap, dom_class, dom_id, class_names, token_list to the per-request view context. Raises RuntimeError if current_helpers is unset.

helpers :text
# ...
truncate(product.description, length: 120)
pluralize(cart.item_count, "item")
dom_id(record)

:link

LinkHelpers. Forwards link_to, mail_to, phone_to, sms_to, current_page? to the per-request view context. Raises RuntimeError if current_helpers is unset.

helpers :link
# ...
link_to(product.name, product_path(product))
mail_to(user.email)

Adding your own helper namespace

Register a namespace in an initializer. Each helper inside it must be named "<Name>Helpers".

# config/initializers/typed_view_model.rb
module MyApp
  module ViewModelHelpers
    module CurrencyHelpers
      def humanize_currency(cents, model)
        # ...
      end
    end
  end
end

TypedViewModel.helper_namespaces << MyApp::ViewModelHelpers

Then helpers :currency resolves to MyApp::ViewModelHelpers::CurrencyHelpers. The gem's own namespace is searched first; subsequent registrations are searched in order.


Traits

Traits are mixin modules carrying view-specific presentation logic that is shared across multiple view-models. Extend the trait module with TypedViewModel::Trait and declare what props it expects.

# app/view_traits/message.rb
module Messages
  module ViewTraits
    module Message
      extend TypedViewModel::Trait

      requires :message

      def sent_at_ms
        (message.sent_at.to_f * 1000).round
      end
    end
  end
end

Mix into a view-model with use:

class MessageViewModel < ApplicationViewModel
  use Messages::ViewTraits::Message

  prop :message, ::Message
end

requires is checked by Base.use on first instantiation: if the host class fails to declare a prop matching every required entry, .new raises ArgumentError with the missing list. The check is deferred to first instantiation so the conventional use Foo before prop :bar ordering still works. Introspect with MyTrait.required_props.


Cache keys

Opt in by including TypedViewModel::WithCacheKey. Declare cache-key sources with with_cache_key:

class ProductCardViewModel < ApplicationViewModel
  prop :product, ::Product
  prop :variant, ::Variant

  with_cache_key :product, :variant
end

Each named source (Symbol → send'd on self; Proc → instance_eval'd) is hashed via TypedViewModel::HashedKey.call. The view-model itself is filtered out to avoid recursion. The result is memoised under the named slot:

<% cache @card do %>
  <%= render @card %>
<% end %>

The generated cache_key(name = :_collection) produces "<class_name>/<hashed_source_1>/<hashed_source_2>/...", optionally suffixed with cache_key_modifier (defaults to nil). Override cache_key_modifier to scope keys app-wide:

class ApplicationViewModel < TypedViewModel::Base
  include TypedViewModel::WithCacheKey

  private

  def cache_key_modifier
    "#{Rails.application.config.cache_id}/#{I18n.locale}"
  end
end

Multiple named slots are supported via with_cache_key :a, :b, name: :summary; access with cache_key(:summary).


HashedKey utility

TypedViewModel::HashedKey.call(item) turns any object into a stable cache-key fragment, in this dispatch order:

Input Output
Responds to cache_key_with_version (ActiveRecord) item.cache_key_with_version
Responds to cache_key (presenter / view component) item.cache_key
String Digest::SHA1.hexdigest(item)
anything else Digest::SHA1.hexdigest(Marshal.dump(item))

Used internally by WithCacheKey; exposed for direct use.

TypedViewModel::HashedKey.call(product)        # => AR cache_key_with_version
TypedViewModel::HashedKey.call("v1/manifest")  # => SHA1 of the string

Marshal-fallback stability. HashedKey.call falls back to Digest::SHA1.hexdigest(Marshal.dump(item)) for objects that don't respond to cache_key_with_version or cache_key. Marshal output is not guaranteed stable across Ruby major versions or library upgrades that change object structure — a deploy that bumps Ruby or changes the in-memory shape of an object will silently invalidate cached fragments keyed off it. Pass developer-trusted values whose Marshal-shape is known stable (Hash, Array, Numeric, primitives, String). For arbitrary AR-like objects, prefer giving them a cache_key method.


Rendering view models from background jobs

View-models that call helpers (URL helpers, url_for, etc.) from inside a job have no request and no view_context. JobHelpers is the job-side mirror of ControllerHelpers: it wraps each perform in with_current_helpers(shim) { yield }, where shim is a minimal class that mixes in Rails.application.routes.url_helpers and stubs url_for to "#".

class ApplicationJob < ActiveJob::Base
  include TypedViewModel::JobHelpers
end

The gem does not add activejob as a runtime dependency. The around_perform callback only runs inside included do … end, so the concern is harmless to load when no job framework is present — but include-ing it requires the host class to expose around_perform (i.e. an ActiveJob::Base subclass).

ActiveStorage URLs

If your view-models reference ActiveStorage::Blob or ActiveStorage::VariantWithRecord URLs, opt into URL handling explicitly:

class ApplicationJob < ActiveJob::Base
  include TypedViewModel::JobHelpers
  include TypedViewModel::JobHelpers::ActiveStorageUrls
end

This decorates the shim's url_for to dispatch on attachment classes; everything else falls through to the parent shim.

Custom view-context

Override build_view_context_class and call super to splice in your own helper includes (CurrentHelper, TimezoneHelper, currency formatting, etc.):

class ApplicationJob < ActiveJob::Base
  include TypedViewModel::JobHelpers

  private

  def build_view_context_class
    Class.new(super) do
      include CurrentHelper
      include TimezoneHelper
    end
  end
end

Test support

Trait testing without ActiveRecord. Require in your test boot:

# test/test_helper.rb
require "typed_view_model/test_support/trait_test_helpers"

class ApplicationTraitTestCase < ActiveSupport::TestCase
  include TypedViewModel::TestSupport::TraitTestHelpers
end

Then test traits in isolation, with MockObject standing in for the AR record:

class MessageTraitTest < ApplicationTraitTestCase
  setup { testing_trait Messages::ViewTraits::Message }

  test "sent_at_ms returns milliseconds since epoch" do
    instance = with_mock(sent_at: Time.utc(2026, 1, 1))
    assert_equal 1_767_225_600_000, instance.sent_at_ms
  end

  test "requires :message" do
    assert_trait_requires @tested_trait, :message
    assert_raises_missing_prop @tested_trait, :message
  end
end

with_mock(**attrs) infers the prop name from the trait's required_props (must be exactly one). For multi-prop traits, use with_props(prop_a: {...}, prop_b: ...) — symbol-keyed hashes are recursively converted to MockObject instances.


Configuration

Top-level globals:

Setting Default Purpose
TypedViewModel.helper_namespaces Set[TypedViewModel::Helpers] Namespaces searched by Base.helpers(*names). Append Modules or Strings (Strings are const_get'd lazily): TypedViewModel.helper_namespaces << "MyApp::ViewModelHelpers".
TypedViewModel.current_helpers nil Per-request view-context stash. Read internally by the shipped helper modules when they delegate to Rails helpers. Set via TypedViewModel::ControllerHelpers or TypedViewModel.with_current_helpers(value) { … }. Fiber-safe on Rails 7+.

Mutating helper_namespaces after helpers calls have already run does not retroactively re-resolve — they're resolved at class-definition time.


API reference

TypedViewModel::Base < Literal::Data

Method Kind Description
use(trait_module) class include the trait. Validates requires on first .new().
helpers(*names) class Include each "#{Name}Helpers" resolved from TypedViewModel.helper_namespaces. Raises ArgumentError if unknown.
prop(name, type, **opts) class (Literal) Declare a typed prop. Full Literal::Data API available.

TypedViewModel::Trait

Module mixed via extend.

Method Description
requires(*names) Declare required prop names. Replaces, does not merge.
required_props Array<Symbol>; [] if requires never called.

TypedViewModel::HashedKey

Method Description
.call(item) Returns a stable hash for item. See dispatch table above.

TypedViewModel::WithCacheKey

ActiveSupport::Concern. Opt in with include.

Method Kind Description
with_cache_key(*sources, name: :_collection) class Register named cache-key sources (Symbols send'd, Procs instance_eval'd).
named_cache_key_attributes class Read-only Hash of registered slots.
cache_key(name = :_collection) instance Memoised cache-key String. Format: "<class>/<hash1>/<hash2>/.../<modifier?>". Raises if blank.
cacheable? instance true if cache_key is defined.
cache_key_modifier instance nil by default. Override to append app-wide scope.

TypedViewModel::Helpers::*

Module Methods
I18nHelpers t, translate, l, localize
FormatHelpers number_to_currency, number_with_precision, number_to_percentage, number_with_delimiter, number_to_human, number_to_human_size, number_to_phone, distance_of_time_in_words, time_ago_in_words
PathHelpers url_helpers, plus *_path / *_url via method_missing
UrlHelpers url_for(source)
TagHelpers content_tag, sanitize, safe_join, tag
TextHelpers truncate, pluralize, simple_format, excerpt, highlight, word_wrap, dom_class, dom_id, class_names, token_list
LinkHelpers link_to, mail_to, phone_to, sms_to, current_page?

TypedViewModel::TestSupport::*

Class / module Description
MockObject Lightweight mock; attribute methods defined as singleton methods (Procs called lazily). Unknown *? methods return nil.
TraitTestHarness.create(trait, **props) Anonymous class including trait with attr_readers; raises ArgumentError on missing required props. Calls after_initialize if defined.
TraitTestHarness.required_props(trait) [] for unextended modules.
TraitTestHelpers Mixin: trait_harness, mock_object / mock_chain, assert_trait_requires, assert_raises_missing_prop, testing_trait, with_props, with_mock.

TypedViewModel (top-level)

Method Description
.helper_namespaces Mutable Set<Module | String>; default Set[TypedViewModel::Helpers].
.current_helpers Reader for the per-request view-context stash.
.current_helpers= Writer for the per-request view-context stash.
.with_current_helpers(value) { … } Set the stash for the duration of the block; restore previous value on exit (including on raise).

TypedViewModel::ControllerHelpers

ActiveSupport::Concern. Include in ApplicationController to install an around_action that wraps each request in TypedViewModel.with_current_helpers(view_context) { … }.

TypedViewModel::JobHelpers

ActiveSupport::Concern. Include in ApplicationJob to install an around_perform that wraps each perform in TypedViewModel.with_current_helpers(shim_view_context) { … }. build_view_context_class is the override hook.

TypedViewModel::JobHelpers::ActiveStorageUrls

ActiveSupport::Concern. Opt-in extension for hosts using ActiveStorage; decorates the shim's url_for to handle ActiveStorage::Blob and ActiveStorage::VariantWithRecord.

Generator

rails generate typed_view_model:install

Writes app/lib/application_view_model.rb and config/initializers/typed_view_model.rb. The generated files are yours to edit. Job-side wiring is opt-in: include TypedViewModel::JobHelpers directly in ApplicationJob.


What this does NOT do

  • Render anything. No partial dispatch, no template, no call. Use ViewComponent or plain partials for rendering.
  • Forward to a wrapped record. No method_missing to the underlying model. Declare props or expose via explicit methods.
  • Enforce trait requires at runtime. That's Literal::Data's job on the host class.
  • Provide a real view context inside jobs. JobHelpers installs a shim — URL helpers and a stubbed url_for. It does not run partials, evaluate ERB, or expose the full ActionView::Base surface.
  • Validate. View-models are display objects; data is assumed to already be valid by the time it gets here. For input validation see typed_form_model.

Stability

1.0.0. The API is in production use across the parent codebase and follows semver going forward. Caveats:

  • The shape of TestSupport (especially with_mock's single-prop assumption) may shift in a future major.
  • RBS sigs are partial.
  • WithCacheKey is exercised through host-app integration tests rather than the in-gem suite.

Development & contributing

bin/setup
bundle exec rake test

Style: standardrb. RBS signatures live under sig/ (partial coverage).

PRs welcome. If you change public API, update the README and CHANGELOG.md in the same commit.


License

MIT. See LICENSE.txt.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages