<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Shankproof]]></title><description><![CDATA[Shankproof]]></description><link>https://shankproof.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 19 May 2026 09:52:30 GMT</lastBuildDate><atom:link href="https://shankproof.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building Range Session Tracker in Public, and Getting It Ready for Google Play]]></title><description><![CDATA[I've been building a small Android-first Flutter app called Range Session Tracker.
The idea is simple: help golfers log range sessions quickly and review distance-control patterns over time without tu]]></description><link>https://shankproof.dev/building-range-session-tracker-in-public-and-getting-it-ready-for-google-play</link><guid isPermaLink="true">https://shankproof.dev/building-range-session-tracker-in-public-and-getting-it-ready-for-google-play</guid><dc:creator><![CDATA[Ryan Bell]]></dc:creator><pubDate>Thu, 23 Apr 2026 03:40:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67ec96b57b05ee49b5ec4502/30d17cd4-f90f-4150-8e62-c6ccc96c21af.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I've been building a small Android-first Flutter app called <strong>Range Session Tracker</strong>.</p>
<p>The idea is simple: help golfers log range sessions quickly and review distance-control patterns over time without turning practice into data-entry homework.</p>
<p>This is not a launch monitor product. It is not a full swing analytics suite. It is not trying to replace TrackMan, Arccos, or a lesson. It is a focused tool for a smaller problem:</p>
<p>When you're on the range working on distance control, how do you capture just enough information to notice patterns and improve?</p>
<p>That constraint shaped the entire app.</p>
<h2>Why I Built It</h2>
<p>I wanted something I could realistically use between balls at the range.</p>
<p>That pushed the product in a few specific directions:</p>
<ul>
<li><p>large tap-driven controls</p>
</li>
<li><p>minimal typing</p>
</li>
<li><p>simple target selection</p>
</li>
<li><p>estimated miss bands instead of fake precision</p>
</li>
<li><p>local-only persistence</p>
</li>
<li><p>no account system</p>
</li>
<li><p>no sync</p>
</li>
<li><p>no backend</p>
</li>
</ul>
<p>The point is not to pretend range practice is richer in data than it really is. Most of the time, it's approximate. You know the target. You know whether a shot finished short, pin high, or long. You know whether it leaked left or right. You may not know the exact yardage miss, but you usually know the band.</p>
<p>That turns out to be enough, as long as the app stays fast.</p>
<h2>What The MVP Does</h2>
<p>The current MVP includes:</p>
<ul>
<li><p>editable bag setup with default clubs</p>
</li>
<li><p>fixed-target sessions</p>
</li>
<li><p>random-target sessions</p>
</li>
<li><p>tap-driven shot logging</p>
</li>
<li><p>distance and lateral miss bands</p>
</li>
<li><p>session summaries</p>
</li>
<li><p>local session history</p>
</li>
<li><p>session detail, rename, and delete support</p>
</li>
</ul>
<p>The app stores completed sessions locally on device. No login. No sync. No cloud layer.</p>
<p>That simplicity is intentional. I wanted a real first release, not a platform plan in disguise.</p>
<h2>What Changed As I Built It</h2>
<p>Even a small app picks up real product decisions quickly.</p>
<p>A few examples:</p>
<ul>
<li><p>shot entry originally felt more form-like than range-friendly, so I changed the flow to prioritize miss amount first</p>
</li>
<li><p>summary and history flows needed better navigation and rename/delete support</p>
</li>
<li><p>outdoor readability mattered more than I first gave it credit for, especially for golfers in the 50+ range who do not want to keep grabbing reading glasses between practice shots</p>
</li>
<li><p>testing on a physical Android tablet surfaced a very normal issue: I first installed the wrong kind of APK and hit a native architecture mismatch that never showed up in the emulator</p>
</li>
</ul>
<p>That last one was a good reminder that device testing still matters, even for a small app.</p>
<h2>Where It Is Now</h2>
<p>At this point:</p>
<ul>
<li><p>the app runs in the Android emulator</p>
</li>
<li><p>the app runs on a physical Android 15 tablet</p>
</li>
<li><p>the Play upload key is created</p>
</li>
<li><p>the signed release bundle is built</p>
</li>
<li><p>the Play Store listing is set up</p>
</li>
<li><p>the screenshots, icon, feature graphic, and privacy policy are in place</p>
</li>
</ul>
<p>Which means I am now in the least glamorous and most real part of shipping: <em>store setup, release tracks, policy forms, and recruiting enough testers to satisfy Google Play's closed-testing requirements</em>.</p>
<h2>The Current Hurdle</h2>
<p>For a new app like this, Google Play requires a closed test before I can apply for production access.</p>
<p>That means I need at least <strong>12 testers</strong> opted into the closed test for <strong>14 continuous days</strong>.</p>
<p>So at the moment, the main job is not coding. It's finding enough Android testers to get through that gate.</p>
<h2>What I Need Next</h2>
<p>If you're an Android golfer and you'd be open to helping test <strong>Range Session Tracker</strong>, I'd love to hear from you.</p>
<p>The ask is simple:</p>
<ul>
<li><p>join the Google Play closed test</p>
</li>
<li><p>stay opted in for 14 days</p>
</li>
<li><p>try the app if you can</p>
</li>
<li><p>send feedback if anything feels rough or confusing</p>
</li>
</ul>
<p>If that sounds interesting, message me directly or reply wherever you found this post.</p>
<p>This has been a fun project to build in public because it keeps the work honest. It's easy to talk about ideas. It's better to talk about what actually shipped, what changed, what broke on real hardware, and what still stands between "working app" and "available app."</p>
<p>Range Session Tracker is real, it's running, and now it needs testers.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67ec96b57b05ee49b5ec4502/b056f1dc-9f30-4f73-8d76-652b9bdc9965.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/67ec96b57b05ee49b5ec4502/53358a73-448d-439d-b39c-73be30b6454f.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/67ec96b57b05ee49b5ec4502/f33a27f5-f49c-4777-91f2-a44d9c6b73b1.png" alt="" style="display:block;margin:0 auto" />

<img src="https://cdn.hashnode.com/uploads/covers/67ec96b57b05ee49b5ec4502/49a68470-6c69-4ec5-9fff-7f9005450a83.png" alt="" style="display:block;margin:0 auto" />]]></content:encoded></item><item><title><![CDATA[Queue Depth Is Not Enough: Adding Workload Metrics to BullMQ]]></title><description><![CDATA[I built bull-der-dash because I wanted a simple, production-friendly way to see what was happening inside our BullMQ queues.
BullMQ is excellent. Redis-backed queues, retries, delayed jobs, priorities]]></description><link>https://shankproof.dev/queue-depth-is-not-enough-adding-workload-metrics-to-bullmq</link><guid isPermaLink="true">https://shankproof.dev/queue-depth-is-not-enough-adding-workload-metrics-to-bullmq</guid><category><![CDATA[bullmq]]></category><category><![CDATA[Redis]]></category><category><![CDATA[Go Language]]></category><category><![CDATA[#prometheus]]></category><category><![CDATA[Grafana]]></category><category><![CDATA[observability]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Ryan Bell]]></dc:creator><pubDate>Thu, 16 Apr 2026 03:52:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/67ec96b57b05ee49b5ec4502/0ecdd5ed-1d45-4e5c-b9ab-9cfb6b4aa6d0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I built <a href="https://github.com/kofno/bullderdash">bull-der-dash</a> because I wanted a simple, production-friendly way to see what was happening inside our BullMQ queues.</p>
<p>BullMQ is excellent. Redis-backed queues, retries, delayed jobs, priorities, parent-child flows: it gives you a lot of power. But once you start running real workloads through it, the operational questions get more specific.</p>
<p>At first, queue depth feels like enough.</p>
<p>How many jobs are waiting?</p>
<p>How many are active?</p>
<p>How many failed?</p>
<p>How many completed?</p>
<p>Those are useful questions, and they were the first things <a href="https://github.com/kofno/bullderdash">bull-der-dash</a> answered. The dashboard could show live queue state, inspect jobs, expose Prometheus metrics, and run cheaply in Kubernetes.</p>
<p>But queue depth is not workload visibility.</p>
<p>Eventually I wanted to answer different questions:</p>
<ul>
<li>Which job types are running most often?</li>
<li>Which jobs are slow?</li>
<li>What is the p95 or p99 completion time by job name?</li>
<li>Which workloads are failing?</li>
<li>Are failures isolated to one queue or one job type?</li>
<li>During a spike, what actually changed?</li>
</ul>
<p>A queue can look healthy at the depth level while one specific workload is getting slower. A queue can drain quickly while hiding a rising failure rate. A completed count can tell you that work finished, but not whether the work took 50 milliseconds or 30 seconds.</p>
<p>That gap is what pushed me to add workload metrics.</p>
<h2>The Tempting Bad Idea</h2>
<p>BullMQ stores completed and failed jobs in Redis. Job hashes include useful fields like the job name and timestamps. In particular, BullMQ jobs expose fields such as:</p>
<ul>
<li><code>name</code></li>
<li><code>timestamp</code></li>
<li><code>processedOn</code></li>
<li><code>finishedOn</code></li>
<li><code>failedReason</code></li>
<li><code>attemptsMade</code></li>
</ul>
<p>So the obvious idea is simple:</p>
<ol>
<li>Scan the completed and failed job sets.</li>
<li>Read each job hash.</li>
<li>Calculate <code>finishedOn - processedOn</code>.</li>
<li>Export success/failure counts and duration percentiles.</li>
</ol>
<p>That would work in a demo.</p>
<p>It is also exactly the wrong shape for a production monitoring path.</p>
<p>The cost of that design grows with retained job history. If you retain thousands or millions of completed jobs, a metrics collector built around scanning retained jobs becomes more expensive as history grows.</p>
<p>Even worse, if that work happens during a Prometheus scrape, <code>/metrics</code> stops being a cheap in-memory export and becomes an accidental Redis workload.</p>
<p>That is not a small detail.</p>
<p>A monitoring tool should reduce uncertainty. It should not become part of the production problem.</p>
<h2>The Performance Constraint</h2>
<p><code>bull-der-dash</code> already had a performance philosophy:</p>
<ul>
<li>frequently polled pages should stay cheap</li>
<li>queue stats should use lightweight Redis commands like <code>LLEN</code> and <code>ZCARD</code></li>
<li>expensive diagnostics should stay off hot paths</li>
<li><code>/metrics</code> should export in-memory Prometheus data</li>
<li>Redis scans should be treated with suspicion</li>
</ul>
<p>That philosophy matters because queues can be spiky. A normal day might look quiet, then suddenly fan out to hundreds or thousands of queued jobs.</p>
<p>In our case, peak fan-out can mean around 1,500 jobs waiting across BullMQ queues. That is not enormous by distributed systems standards, but it is enough that careless observability can become visible load.</p>
<p>The goal was not merely "add more metrics."</p>
<p>The goal was:</p>
<blockquote>
<p>Add workload visibility without making Redis pay for every scrape.</p>
</blockquote>
<p>That constraint shaped the whole design.</p>
<h2>The Better Source of Truth: BullMQ Events</h2>
<p>BullMQ already emits events.</p>
<p>For each queue, BullMQ writes to an event stream in Redis. Instead of repeatedly asking Redis, "What jobs have ever completed?", the collector can ask, "What has happened since I last checked?"</p>
<p>That changes the scaling model.</p>
<p>Bad model:</p>
<pre><code class="language-text">metric cost ~= retained jobs
</code></pre>
<p>Better model:</p>
<pre><code class="language-text">metric cost ~= newly finished jobs
</code></pre>
<p>That is the key design move.</p>
<p>The workload metrics collector now watches BullMQ event streams in the background. It listens for terminal job events, currently <code>completed</code> and <code>failed</code>.</p>
<p>The implementation lives in the <code>internal/workloadmetrics</code> package in the <a href="https://github.com/kofno/bullderdash">bull-der-dash repository</a>. The important part is not a large framework or a complicated metrics pipeline; it is a small background collector that reads BullMQ events incrementally and records Prometheus metrics in memory.</p>
<p>When one appears, it reads only the fields it needs from the job hash:</p>
<pre><code class="language-redis">HMGET bull:{queue}:{jobId} name processedOn finishedOn
</code></pre>
<p>Then it records Prometheus metrics in memory.</p>
<p>Prometheus scrapes <code>/metrics</code>, and <code>/metrics</code> does not need to query Redis to produce those workload metrics.</p>
<p>The flow looks like this:</p>
<pre><code class="language-text">BullMQ worker
    |
    v
Redis stream: bull:{queue}:events
    |
    v
bull-der-dash workload collector
    |
    +--&gt; HMGET bull:{queue}:{jobId} name processedOn finishedOn
    |
    v
Prometheus counters and histograms
    |
    v
Grafana
</code></pre>
<p>This is the shape I wanted: incremental, cheap, and operationally understandable.</p>
<h2>The Metrics</h2>
<p>The first version exposes a small set of workload metrics.</p>
<p>The metric definitions are in <a href="https://github.com/kofno/bullderdash/tree/main/internal/metrics"><code>internal/metrics</code></a>, and the collector code is in <a href="https://github.com/kofno/bullderdash/tree/main/internal/workloadmetrics"><code>internal/workloadmetrics</code></a>.</p>
<p>Completed and failed job counts:</p>
<pre><code class="language-text">bullmq_jobs_finished_total{queue, name, result}
</code></pre>
<p>Job processing duration:</p>
<pre><code class="language-text">bullmq_job_completion_duration_seconds_bucket{queue, name, result, le}
bullmq_job_completion_duration_seconds_sum{queue, name, result}
bullmq_job_completion_duration_seconds_count{queue, name, result}
</code></pre>
<p>Collector health:</p>
<pre><code class="language-text">bullmq_workload_event_lag_seconds{queue}
bullmq_workload_events_read_total{queue, event}
bullmq_workload_events_dropped_total{queue, reason}
bullmq_workload_job_lookup_errors_total{queue, reason}
</code></pre>
<p>The <code>name</code> label is the BullMQ job name. The <code>result</code> label is either <code>completed</code> or <code>failed</code>.</p>
<p>That gives Grafana enough to answer the questions I actually care about.</p>
<p>For example, completed and failed jobs over a five-minute window:</p>
<pre><code class="language-promql">sum by (queue, name, result) (
  increase(bullmq_jobs_finished_total[5m])
)
</code></pre>
<p>p95 processing duration by queue and job name:</p>
<pre><code class="language-promql">histogram_quantile(
  0.95,
  sum by (le, queue, name) (
    rate(bullmq_job_completion_duration_seconds_bucket[5m])
  )
)
</code></pre>
<p>Failure ratio by queue and job name:</p>
<pre><code class="language-promql">sum by (queue, name) (
  rate(bullmq_jobs_finished_total{result="failed"}[5m])
)
/
sum by (queue, name) (
  rate(bullmq_jobs_finished_total[5m])
)
</code></pre>
<p>Collector lag:</p>
<pre><code class="language-promql">bullmq_workload_event_lag_seconds
</code></pre>
<p>These are simple metrics, but they move the dashboard from "how deep are my queues?" to "what work is actually happening?"</p>
<h2>Cardinality Still Matters</h2>
<p>Adding <code>name</code> as a label is useful, but labels are not free.</p>
<p>Prometheus cardinality can get out of hand if labels contain unbounded values. A queue name is usually safe. A job name is often safe. Arbitrary job data is not.</p>
<p>So the collector does not label metrics by job payload, title, user ID, tenant ID, or anything else that might explode.</p>
<p>It also caps the number of job names per queue.</p>
<p>If a queue exceeds the configured job-name limit, additional job names are grouped under:</p>
<p><code>__other__</code></p>
<p>If a terminal event is observed but the job hash is already gone before the collector can read it, the count is still recorded under:</p>
<p><code>__unknown__</code></p>
<p>That case can happen if completed or failed jobs are removed aggressively. Counts still matter, but duration samples require <code>processedOn</code> and <code>finishedOn</code>.</p>
<p>Those guardrails are not glamorous, but they are the difference between useful production metrics and a Prometheus cardinality incident.</p>
<h2>A Small Architecture Compromise</h2>
<p>Originally, <code>bull-der-dash</code> leaned hard into being stateless.</p>
<p>That was the right default. Stateless HTTP handlers are easy to scale, easy to reason about, and easy to operate.</p>
<p>But event-derived metrics need a tiny bit of state:</p>
<ul>
<li>the last stream ID read per queue</li>
<li>in-memory Prometheus counters and histograms</li>
<li>bounded job-name tracking</li>
</ul>
<p>So I refined the architecture rule.</p>
<p>The web request path stays stateless. The dashboard, job inspection pages, health checks, and <code>/metrics</code> export do not depend on session state.</p>
<p>But optional background collectors are allowed to maintain small, ephemeral telemetry state.</p>
<p>That is a practical distinction:</p>
<p><code>Stateless HTTP serving v. Ephemeral state for telemetry collection.</code></p>
<p>I did not add leader election or distributed coordination in the first version. The current deployment runs a single <code>bull-der-dash</code> instance, so a single in-process collector is the simplest correct design.</p>
<p>If we later run multiple replicas, the collector will need explicit ownership coordination or a separate single-replica deployment. Otherwise multiple pods could read the same BullMQ events and double-count.</p>
<p>That is a good future problem. It did not need to be solved on day one.</p>
<h2>Why Go Has Been a Good Fit</h2>
<p>One of the quiet wins in this project has been the choice to build <code>bull-der-dash</code> in Go.</p>
<p>In my environment, <code>bull-der-dash</code> sits around 12 MB RSS. A TypeScript-native BullMQ dashboard we compared against was closer to 500 MB RSS while offering less of the workload visibility we wanted.</p>
<p>That difference matters.</p>
<p>A monitoring tool should be cheap enough to leave running continuously. It should not become another meaningful workload to operate.</p>
<p>This is not a "Go beats TypeScript" argument. TypeScript is a great application language, and existing BullMQ dashboards are useful. The point is that for production-adjacent tooling, the operating model matters.</p>
<p>Go gives this project a nice shape:</p>
<ul>
<li>one small binary</li>
<li>low idle memory</li>
<li>simple container packaging</li>
<li>straightforward Prometheus integration</li>
<li>cheap background goroutines</li>
<li>predictable Kubernetes footprint</li>
</ul>
<p>That makes it easier to add observability without worrying that the observability tool itself is becoming expensive.</p>
<p>The most important performance feature of an internal monitoring tool might be that you barely notice it running.</p>
<h2>What Changed Operationally</h2>
<p>The rollout was intentionally boring.</p>
<p>The collector is behind a feature flag:</p>
<pre><code class="language-text">WORKLOAD_METRICS_ENABLED=true
</code></pre>
<p>The configuration is exposed through the Helm chart as well, so the same binary can run with workload metrics disabled by default and enabled per environment.</p>
<p>By default, it starts from new events only:</p>
<pre><code class="language-text">WORKLOAD_METRICS_START_ID=$
</code></pre>
<p>That means no startup backfill and no surprise Redis scan. Existing completed jobs are not retroactively counted by the workload metrics. The metrics reflect events observed while the collector is running.</p>
<p>That tradeoff is fine for operational dashboards.</p>
<p>For rollout, the sequence was:</p>
<ol>
<li>Enable in a local simulator environment.</li>
<li>Confirm event reads.</li>
<li>Confirm completed job counts.</li>
<li>Confirm duration histograms.</li>
<li>Watch collector lag.</li>
<li>Deploy to demo.</li>
<li>Build Grafana panels manually.</li>
<li>Promote to production.</li>
</ol>
<p>That is exactly the kind of rollout I want for production-adjacent tooling: incremental, visible, and easy to back out.</p>
<h2>What I Can See Now</h2>
<p>With the new metrics, Grafana can show:</p>
<ul>
<li>completed jobs by queue and job name</li>
<li>failed jobs by queue and job name</li>
<li>p95/p99 duration by workload</li>
<li>failure ratios</li>
<li>event collector lag</li>
<li>lookup errors when job hashes disappear too quickly</li>
<li>whether job-name cardinality is escaping expectations</li>
</ul>
<p>That is a much better operational picture than queue depth alone.</p>
<p>Queue depth still matters. It tells me about backlog.</p>
<p>But workload metrics tell me about behavior.</p>
<p>That distinction is the whole point.</p>
<h2>Project Links</h2>
<p>The project is open source here:</p>
<ul>
<li><a href="https://github.com/kofno/bullderdash">bull-der-dash on GitHub</a></li>
<li><a href="https://github.com/kofno/bullderdash#helm-install-ghcr">Helm chart and deployment docs</a></li>
<li><a href="https://github.com/kofno/bullderdash#workload-metrics">Workload metrics usage examples</a></li>
</ul>
<p>If you are running BullMQ and want lightweight visibility into queue depth, job inspection, Prometheus metrics, and workload duration percentiles, I would love feedback or issues.</p>
<h2>Closing Thought</h2>
<p>The lesson from this work is not specific to BullMQ.</p>
<p>It is a general observability lesson:</p>
<blockquote>
<p>The easiest metric to compute is not always the safest metric to collect.</p>
</blockquote>
<p>For BullMQ, scanning retained jobs would have been easy to understand, easy to implement, and easy to regret.</p>
<p>Reading event streams incrementally is a better fit for the system. It gives us the workload visibility we need while preserving the performance characteristics that made <code>bull-der-dash</code> useful in the first place.</p>
<p>Observability should make production clearer.</p>
<p>It should not make Redis sad.</p>
]]></content:encoded></item><item><title><![CDATA[What’s in the Bag: Java, But Make It Modern]]></title><description><![CDATA[Part 1 of a new series on JVM technologies, Kotlin, and the tools worth your time in 2025.
For the past three decades, I’ve had a complicated relationship with Java. It was one of the first languages I wrote “real” software in, but over the years I d...]]></description><link>https://shankproof.dev/whats-in-the-bag-java-but-make-it-modern</link><guid isPermaLink="true">https://shankproof.dev/whats-in-the-bag-java-but-make-it-modern</guid><category><![CDATA[Java]]></category><category><![CDATA[Kotlin]]></category><category><![CDATA[kotlin beginner]]></category><category><![CDATA[jvm]]></category><category><![CDATA[programming languages]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Types]]></category><category><![CDATA[Jetbrains]]></category><category><![CDATA[Modern Java]]></category><category><![CDATA[ktor]]></category><category><![CDATA[Build In Public]]></category><dc:creator><![CDATA[Ryan Bell]]></dc:creator><pubDate>Fri, 23 May 2025 02:01:04 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1747965288902/6917e6c6-ed96-4d8f-bd19-e5f90667d144.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>Part 1 of a new series on JVM technologies, Kotlin, and the tools worth your time in 2025.</em></p>
<p>For the past three decades, I’ve had a complicated relationship with Java. It was one of the first languages I wrote “real” software in, but over the years I drifted toward more expressive, flexible ecosystems — Ruby, Elixir, TypeScript. Java always felt like something you had to fight a little to enjoy.</p>
<p>But lately, something’s shifted.</p>
<p>I’ve been circling back to the JVM, not because I miss Java, but because I’ve been pulled in by <strong>Kotlin</strong> — a language that manages to feel modern, expressive, and dare I say… joyful?</p>
<p>If Java is the multi-tool that gets the job done, Kotlin is the well-balanced wedge that just feels right in your hand.</p>
<hr />
<h2 id="heading-so-whats-this-series">So what’s this series?</h2>
<p>This “What’s in the Bag” JVM series is my way of exploring the tools and ideas that are making Java exciting again — at least for me. I'm focusing less on traditional enterprise stacks and more on <strong>lightweight, elegant technologies</strong> that feel closer to the kind of development I enjoy in TypeScript.</p>
<p>Here’s what you can expect:</p>
<ol>
<li><p><strong>A Kotlin primer</strong> – Why I think it’s the best thing I’ve used on the JVM in a long time.</p>
</li>
<li><p><strong>A look at Ktor</strong> – A JetBrains-built server framework that makes writing APIs in Kotlin surprisingly fun.</p>
</li>
<li><p><strong>Some working examples</strong> – APIs, dev tools, and patterns that remind me of the joy of fast iteration.</p>
</li>
<li><p><strong>A few comparisons</strong> – Why these tools are worth a second look even if you’ve moved past Java.</p>
</li>
</ol>
<hr />
<h2 id="heading-why-kotlin">Why Kotlin?</h2>
<p>Kotlin hits a sweet spot that few languages do:</p>
<ul>
<li><p><strong>Null safety that works</strong></p>
</li>
<li><p><strong>Coroutines and structured concurrency</strong></p>
</li>
<li><p><strong>First-class tooling from JetBrains</strong></p>
</li>
<li><p><strong>Concise syntax with real type safety</strong></p>
</li>
</ul>
<p>And maybe most surprisingly — it’s just <em>fun</em> to write.</p>
<p>If you’ve ever thought <em>“I wish JavaScript had a type system that didn’t hate me”</em>, or <em>“I wish TypeScript ran on the server with real threads”</em>, Kotlin might be your thing.</p>
<hr />
<h2 id="heading-where-this-is-going">Where this is going</h2>
<p>I’m going to keep each entry focused, digestible, and grounded in real code. The goal isn’t to teach Kotlin from scratch — it’s to show what makes these tools <strong>worth your time</strong> in 2025, even if you never thought you’d look at a <code>.kt</code> file again.</p>
<p>Let’s get back in the bag.</p>
<hr />
<h3 id="heading-next-up-kotlin-for-typescript-developers">Next Up: Kotlin for TypeScript Developers</h3>
<p>We'll explore Kotlin's core features, what makes it elegant, and how it compares to the tools you already know.</p>
]]></content:encoded></item><item><title><![CDATA[What Really Lowers Your Score? Modeling the Truth Behind Strokes Gained]]></title><description><![CDATA[🏌️ What Really Lowers Your Score?
I’ve always been curious about what really lowers golf scores.
Not just what feels important — but what the data actually says.
So I started building a model.And I’m already learning things that surprised me.

📊 No...]]></description><link>https://shankproof.dev/what-really-lowers-your-score-modeling-the-truth-behind-strokes-gained</link><guid isPermaLink="true">https://shankproof.dev/what-really-lowers-your-score-modeling-the-truth-behind-strokes-gained</guid><category><![CDATA[golf data]]></category><category><![CDATA[strokes gained analysis]]></category><category><![CDATA[pga data science]]></category><category><![CDATA[strokes gained]]></category><category><![CDATA[lower golf scores]]></category><category><![CDATA[golf analytics]]></category><category><![CDATA[golf performance model]]></category><category><![CDATA[golf stats interpretation]]></category><dc:creator><![CDATA[Ryan Bell]]></dc:creator><pubDate>Wed, 23 Apr 2025 03:04:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1745378147313/c51d22d1-fb0a-46c4-b526-7e35872b2004.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-what-really-lowers-your-score">🏌️ What Really Lowers Your Score?</h2>
<p>I’ve always been curious about what <em>really</em> lowers golf scores.</p>
<p>Not just what feels important — but what the data actually says.</p>
<p>So I started building a model.<br />And I’m already learning things that surprised me.</p>
<hr />
<h2 id="heading-not-all-strokes-are-equal">📊 Not All Strokes Are Equal</h2>
<p>Strokes Gained is a powerful stat.<br />But at its core, it assumes one thing:</p>
<blockquote>
<p>A stroke gained is a stroke gained, no matter where you earn it.</p>
</blockquote>
<p>That might be true in theory.<br />But what if <em>how</em> you gain or lose strokes matters more than we think?</p>
<hr />
<h2 id="heading-youve-heard-the-debate">🧠 You've Heard the Debate:</h2>
<blockquote>
<p><em>“Drive for show, putt for dough.”</em><br /><em>“The driver is the most important club in the bag.”</em></p>
</blockquote>
<p>Depending on who you ask, the most valuable club in your bag changes.</p>
<p>I wanted to know what the <strong>data</strong> says — not just the tour chatter.</p>
<p>So I built a model and ran it on <strong>7 full seasons of PGA Tour data, from 2015 to 2022</strong>, using round-level strokes gained stats and computed scoring differentials.</p>
<hr />
<h2 id="heading-early-results-from-the-pga-tour">📈 Early Results from the PGA Tour</h2>
<p>Here’s what I found:</p>
<ul>
<li><p>✅ <strong>Approach play</strong> is the most consistent driver of lower scores</p>
</li>
<li><p>✅ <strong>Putting</strong> is right behind — and sometimes just as important</p>
</li>
<li><p>✅ <strong>Off-the-tee skill</strong> helps, but contributes less directly</p>
</li>
<li><p>✅ <strong>Short game</strong> plays a supporting role, not a starring one</p>
</li>
</ul>
<p>That’s the big picture.<br />But when you zoom in on individual players, the story gets more interesting.</p>
<hr />
<h2 id="heading-everyone-scores-differently">🔍 Everyone Scores Differently</h2>
<p>I ran individual models on dozens of PGA players — and the variation was striking.</p>
<ul>
<li><p>🧨 Some players like <strong>Erik van Rooyen</strong> and <strong>Daniel Chopra</strong> rely heavily on Off-the-Tee or Short Game performance to lower scores</p>
</li>
<li><p>🎯 Others like <strong>Victor Perez</strong> or <strong>Mark Wilson</strong> live and die by the putter</p>
</li>
<li><p>🛠 Players like <strong>Rory McIlroy</strong> and <strong>Sean O’Hair</strong> show balanced, all-around scoring profiles</p>
</li>
</ul>
<blockquote>
<p>Every scorer has a different recipe.<br />And the model shows how they get it done.</p>
</blockquote>
<hr />
<h2 id="heading-how-you-can-help">🗣 How You Can Help</h2>
<p>Now I want to go further.</p>
<p>I'm starting to collect <strong>amateur and VR golf data</strong> to see how these patterns shift at lower skill levels.</p>
<ul>
<li><p>Are drivers more important when you’re missing more greens?</p>
</li>
<li><p>Does putting get messier as you move away from elite ranks?</p>
</li>
<li><p>Do different skill levels <em>need different practice priorities</em>?</p>
</li>
</ul>
<p>If you're a golfer — real-world or VR — and want to help, I’d love your input.</p>
<p>Even just a few rounds of data — Score, Differential, and basic strokes gained stats — can help expand the model.</p>
<blockquote>
<p><strong>Want to contribute?</strong><br />A simple upload form is coming soon. Until then, feel free to reach out.</p>
</blockquote>
<hr />
<h2 id="heading-whats-coming-next">🧠 What’s Coming Next</h2>
<p>In future posts, I’ll be sharing:</p>
<ul>
<li><p>How PGA Tour players actually score — by skill, not by myth</p>
</li>
<li><p>What patterns emerge at the amateur level</p>
</li>
<li><p>What this means for your practice, your stats, and your game</p>
</li>
</ul>
<p>This is just the beginning.<br />Let’s find out what really matters — and maybe help a few golfers score lower along the way.</p>
<hr />
<p>📝 <em>Want to follow along or contribute your own rounds? You can find me at</em> <a target="_blank" href="https://shankproof.dev"><em>shankproof.dev</em></a> <em>or drop a comment below.</em><br />👊</p>
]]></content:encoded></item><item><title><![CDATA[What’s in the Bag? Building Fairway Tasks with Deno, Fresh, and SSE]]></title><description><![CDATA[In Part 1 of this series, I introduced the idea of looking at development stacks the way golfers look at their gear. Every tool in the bag has a purpose. Some we reach for without thinking. Others we try out, experiment with, and either adopt or toss...]]></description><link>https://shankproof.dev/whats-in-the-bag-building-fairway-tasks-with-deno-fresh-and-sse</link><guid isPermaLink="true">https://shankproof.dev/whats-in-the-bag-building-fairway-tasks-with-deno-fresh-and-sse</guid><category><![CDATA[minimal-stack]]></category><category><![CDATA[Deno]]></category><category><![CDATA[fresh]]></category><category><![CDATA[SSE]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Build In Public]]></category><category><![CDATA[realtime]]></category><category><![CDATA[full stack]]></category><dc:creator><![CDATA[Ryan Bell]]></dc:creator><pubDate>Sun, 13 Apr 2025 03:19:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1744514131480/bb15a963-d0a7-4979-85de-cc6815864bb1.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In <a target="_blank" href="https://shankproof.dev/whats-in-the-bag?source=more_series_bottom_blogs">Part 1 of this series</a>, I introduced the idea of looking at development stacks the way golfers look at their gear. Every tool in the bag has a purpose. Some we reach for without thinking. Others we try out, experiment with, and either adopt or toss aside. This week, I finally teed off on the first build: <strong>Fairway Tasks</strong>, a collaborative to-do app powered by <strong>Deno</strong>, <strong>Fresh</strong>, and <strong>Server-Sent Events (SSE)</strong>.</p>
<p>The goal was to build a real-world demo that shows off the strengths of this stack—without turning it into a full-blown product. I wanted persistence, live updates, and minimal complexity.</p>
<hr />
<h3 id="heading-what-were-building">What We’re Building</h3>
<p>Fairway Tasks is a collaborative to-do list that allows anyone to:</p>
<ul>
<li><p>Add tasks</p>
</li>
<li><p>Mark them as complete</p>
</li>
<li><p>See updates in real-time across all open tabs</p>
</li>
</ul>
<p>There’s no login system, no multi-user tracking, and no external database. Just the core interactions and live state syncing.</p>
<p>But there <em>is</em> one key addition to the base demo: I used <strong>Deno’s built-in KV store</strong> to persist the data. It’s a small change from an in-memory store, but it showcases how batteries-included Deno really is.</p>
<hr />
<h3 id="heading-the-stack">The Stack</h3>
<p>Here’s what I used:</p>
<ul>
<li><p><strong>Deno</strong>: runtime with built-in TypeScript, security, testing, and KV storage</p>
</li>
<li><p><strong>Fresh</strong>: a modern web framework with island-based rendering</p>
</li>
<li><p><strong>SSE (Server-Sent Events)</strong>: for real-time updates</p>
</li>
<li><p><strong>KV Store</strong>: Deno’s built-in persistence layer</p>
</li>
</ul>
<p>Together, this gave me a stack with no extra tooling or dependency setup. Just native capabilities and good architecture choices.</p>
<hr />
<h3 id="heading-diving-into-the-code">Diving into the Code</h3>
<p>Fairway Tasks has a minimal but purposeful codebase. Here’s a closer look at the moving parts and how they interact.</p>
<p><strong>1. API Routes</strong></p>
<p>All task interactions live in <code>routes/api/tasks.ts</code>. It handles:</p>
<ul>
<li><p><code>GET</code> – Return all tasks from the Deno KV store</p>
</li>
<li><p><code>POST</code> – Create a new task, store it, and broadcast it</p>
</li>
<li><p><code>PATCH</code> – Mark a task complete and broadcast the update</p>
</li>
<li><p><code>DELETE</code> – Remove a task from storage and notify all clients</p>
</li>
</ul>
<p>All task mutations trigger the <code>broadcast()</code> function from <code>routes/api/stream.ts</code>, sending the action over Server-Sent Events to connected tabs.</p>
<p><strong>2. Server-Sent Events (SSE)</strong></p>
<p>The real-time update system is handled via a simple SSE implementation:</p>
<ul>
<li><p>Clients connect to <code>/api/stream</code></p>
</li>
<li><p>A <code>Set</code> of writable clients is maintained</p>
</li>
<li><p>On any task change, a message is encoded and sent to each connected writer</p>
</li>
</ul>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">broadcast</span>(<span class="hljs-params">data: unknown</span>) </span>{
  <span class="hljs-keyword">const</span> msg = <span class="hljs-string">`data: <span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.stringify(data)}</span>\n\n`</span>;
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> writer <span class="hljs-keyword">of</span> clients) {
    writer.write(<span class="hljs-keyword">new</span> TextEncoder().encode(msg));
  }
}
</code></pre>
<p>It’s lightweight, doesn’t require any external library, and perfect for small collaborative experiences.</p>
<p><strong>3. Frontend Islands</strong></p>
<p>The interactive frontend lives in <code>islands/TaskInput.tsx</code>, where:</p>
<ul>
<li><p>Tasks are displayed from initial props</p>
</li>
<li><p>Users can add, complete, or delete tasks with a minimal UI</p>
</li>
<li><p>The island listens to the SSE stream and updates local state accordingly</p>
</li>
</ul>
<p>With the recent addition of <a target="_blank" href="https://github.com/kofno/jsonous">Jsonous</a>, the SSE message decoding now looks like this:</p>
<pre><code class="lang-typescript">useEffect(<span class="hljs-function">() =&gt;</span> {
  <span class="hljs-keyword">const</span> sse = <span class="hljs-keyword">new</span> EventSource(<span class="hljs-string">"/api/stream"</span>);
  sse.onmessage = <span class="hljs-function">(<span class="hljs-params">msg</span>) =&gt;</span> {
    sseEventDecoder.decodeJson(msg.data).cata({
      Ok: <span class="hljs-function">(<span class="hljs-params">event</span>) =&gt;</span> {
        <span class="hljs-keyword">if</span> (event.type === <span class="hljs-string">"add"</span>) {
          setTasks(<span class="hljs-function">(<span class="hljs-params">prev</span>) =&gt;</span> [...prev, event.task]);
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (event.type === <span class="hljs-string">"complete"</span>) {
          setTasks(<span class="hljs-function">(<span class="hljs-params">prev</span>) =&gt;</span>
            prev.map(<span class="hljs-function">(<span class="hljs-params">t</span>) =&gt;</span> t.id === event.id ? { ...t, completed: <span class="hljs-literal">true</span> } : t)
          );
        } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (event.type === <span class="hljs-string">"delete"</span>) {
          setTasks(<span class="hljs-function">(<span class="hljs-params">prev</span>) =&gt;</span> prev.filter(<span class="hljs-function">(<span class="hljs-params">t</span>) =&gt;</span> t.id !== event.id));
        }
      },
      Err: <span class="hljs-function">(<span class="hljs-params">err</span>) =&gt;</span> {
        <span class="hljs-built_in">console</span>.error(err);
      },
    });
  };
  <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> sse.close();
}, []);
</code></pre>
<p>This small change brings clarity, safety, and makes the SSE message format easier to evolve over time.</p>
<p><strong>Bonus: Deno KV Integration</strong></p>
<p>Persistence is handled with Deno’s built-in KV store using a <code>utils/kv.ts</code> helper module. Tasks are saved under a <code>task:&lt;id&gt;</code> key. Fetching is done with a simple <code>kv.list()</code> call.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getTasks</span>(<span class="hljs-params"></span>): <span class="hljs-title">Promise</span>&lt;<span class="hljs-title">Task</span>[]&gt; </span>{
  <span class="hljs-keyword">const</span> entries = [];
  <span class="hljs-keyword">for</span> <span class="hljs-keyword">await</span> (<span class="hljs-keyword">const</span> res <span class="hljs-keyword">of</span> kv.list&lt;Task&gt;({ prefix: [<span class="hljs-string">"task"</span>] })) {
    entries.push(res.value);
  }
  <span class="hljs-keyword">return</span> entries;
}
</code></pre>
<p>The result? A real-time, collaborative app with no third-party DB or state store.</p>
<h3 id="heading-what-worked-well">What Worked Well</h3>
<ul>
<li><p><strong>Fresh’s simplicity</strong>: Routing, rendering, and hydration are all predictable and fast</p>
</li>
<li><p><strong>SSE was perfect</strong>: Minimal setup, great fit for one-way sync like this</p>
</li>
<li><p><strong>Deno’s DX</strong>: Linting, testing, and dev server all just work</p>
</li>
<li><p><strong>KV store</strong>: Simple, stable, and lets the app persist between restarts with almost no config</p>
</li>
</ul>
<hr />
<h3 id="heading-what-id-explore-next">What I’d Explore Next</h3>
<ul>
<li><p><strong>Adding authentication</strong> using session cookies or tokens</p>
</li>
<li><p><strong>Using external DBs</strong> if relational needs increase</p>
</li>
<li><p><strong>More complex event handling</strong> (editing tasks, reordering, undo)</p>
</li>
<li><p><strong>Offline sync and background queuing</strong></p>
</li>
<li><p><strong>More Robust SSE</strong> (something that scales horizontally and watches the db for changes)</p>
</li>
</ul>
<hr />
<h3 id="heading-wrap-up">Wrap-up</h3>
<p>Fairway Tasks started as a small project—a swing at trying something simple, collaborative, and live. It turned into a rewarding dive into the capabilities of Deno and Fresh. Using only built-in tools like the KV store and SSE, I was able to build a functional app with real-time syncing and data persistence, all without touching external services or bolting on extra complexity.</p>
<p>The island-based approach of Fresh made it easy to separate server-rendered state from interactive components, and adding <code>jsonous</code> brought confidence to decoding live updates. It’s refreshing to build something that feels cohesive, productive, and fun.</p>
<p>There’s still room to grow—authentication, offline support, and more resilient streaming—but as it stands, Fairway Tasks is a solid example of what modern minimal stacks can accomplish.</p>
<p>You can try the app <a target="_blank" href="https://ryanlbell-fairway-tas-96.deno.dev/">live on Deno Deploy</a>, and view the source code on <a target="_blank" href="https://github.com/kofno/fairway-tasks">GitHub</a>.</p>
<p>Next up? I might take a swing with <strong>Wasp</strong>, <strong>Effect</strong>, or even try a mobile-first build using <strong>Tamagui</strong>. Let me know what you'd like to see in the next post—and what’s in <em>your</em> bag these days.</p>
<p>Thanks for reading!</p>
]]></content:encoded></item><item><title><![CDATA[Streamlined Discriminated Union Decoding in TypeScript with jsonous's New Decoder]]></title><description><![CDATA[TypeScript developers love discriminated unions (or tagged unions). They provide a fantastic way to model states, events, or different kinds of data structures in a type-safe manner. When working with external data sources like JSON APIs, however, de...]]></description><link>https://shankproof.dev/streamlined-discriminated-union-decoding-in-typescript-with-jsonouss-new-decoder</link><guid isPermaLink="true">https://shankproof.dev/streamlined-discriminated-union-decoding-in-typescript-with-jsonouss-new-decoder</guid><category><![CDATA[TypeScript]]></category><category><![CDATA[json]]></category><category><![CDATA[Discriminated Union Types]]></category><category><![CDATA[Validation]]></category><category><![CDATA[TypeSafety]]></category><category><![CDATA[Functional Programming]]></category><category><![CDATA[error handling]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Ryan Bell]]></dc:creator><pubDate>Sat, 12 Apr 2025 03:57:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1744430037053/1ead4c9c-03ae-4554-beff-3447913421fc.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>TypeScript developers love discriminated unions (or tagged unions). They provide a fantastic way to model states, events, or different kinds of data structures in a type-safe manner. When working with external data sources like JSON APIs, however, decoding these unions reliably can sometimes feel a bit cumbersome.</p>
<p>Here at <code>jsonous</code>, we aim to make JSON decoding as painless and type-safe as possible. While our existing <code>oneOf</code> decoder could handle unions, it wasn't specifically optimized for the common discriminated union pattern. Today, we're excited to introduce a new tool designed precisely for this job: the <code>discriminatedUnion</code> decoder!</p>
<h3 id="heading-the-challenge-decoding-discriminated-unions-the-old-way">The Challenge: Decoding Discriminated Unions "The Old Way"</h3>
<p>Let's consider a common example: representing different types of users in our system.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// Our TypeScript types</span>
<span class="hljs-keyword">interface</span> User {
  <span class="hljs-keyword">type</span>: <span class="hljs-string">'user'</span>;
  id: <span class="hljs-built_in">string</span>;
  name: <span class="hljs-built_in">string</span>;
  isActive: <span class="hljs-built_in">boolean</span>;
}

<span class="hljs-keyword">interface</span> Admin {
  <span class="hljs-keyword">type</span>: <span class="hljs-string">'admin'</span>;
  id: <span class="hljs-built_in">string</span>;
  name: <span class="hljs-built_in">string</span>;
  permissions: <span class="hljs-built_in">string</span>[];
}

<span class="hljs-keyword">type</span> Person = User | Admin;
</code></pre>
<p>We have a <code>Person</code> type which can be either a <code>User</code> or an <code>Admin</code>, distinguished by the <code>type</code> field.</p>
<p>Using <code>jsonous</code> previously, you'd typically define decoders for each variant and combine them with <code>oneOf</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> {
  <span class="hljs-built_in">string</span>,
  <span class="hljs-built_in">boolean</span>,
  array,
  stringLiteral,
  createDecoderFromStructure,
  oneOf,
  Decoder,
  identity <span class="hljs-comment">// Needed for mapping</span>
} <span class="hljs-keyword">from</span> <span class="hljs-string">'jsonous'</span>;

<span class="hljs-comment">// Decoders for each variant</span>
<span class="hljs-keyword">const</span> userDecoder: Decoder&lt;User&gt; = createDecoderFromStructure({
  <span class="hljs-keyword">type</span>: stringLiteral(<span class="hljs-string">'user'</span>),
  id: <span class="hljs-built_in">string</span>,
  name: <span class="hljs-built_in">string</span>,
  isActive: <span class="hljs-built_in">boolean</span>,
});

<span class="hljs-keyword">const</span> adminDecoder: Decoder&lt;Admin&gt; = createDecoderFromStructure({
  <span class="hljs-keyword">type</span>: stringLiteral(<span class="hljs-string">'admin'</span>),
  id: <span class="hljs-built_in">string</span>,
  name: <span class="hljs-built_in">string</span>,
  permissions: array(<span class="hljs-built_in">string</span>),
});

<span class="hljs-comment">// --- The "Old Way" using oneOf ---</span>
<span class="hljs-keyword">const</span> personDecoderOneOf: Decoder&lt;Person&gt; = oneOf([
  <span class="hljs-comment">// We need to explicitly map each decoder to the union type</span>
  userDecoder.map&lt;Person&gt;(identity),
  adminDecoder.map&lt;Person&gt;(identity),
]);
</code></pre>
<p>This works, but has a few drawbacks:</p>
<ol>
<li><p><strong>Verbosity:</strong> You need <code>.map&lt;Person&gt;(identity)</code> for every single variant. This adds boilerplate, especially with many variants.</p>
</li>
<li><p><strong>Less Specific Errors:</strong> If decoding fails, <code>oneOf</code> tries <em>every</em> decoder in the list and reports <em>all</em> failures. For a discriminated union, you often intuitively know <em>which</em> variant <em>should</em> have matched based on the <code>type</code> field, making the other errors noise.</p>
</li>
<li><p><strong>Potential Inefficiency:</strong> <code>oneOf</code> might run multiple potentially complex decoders even if the <code>type</code> field clearly indicates only one is relevant.</p>
</li>
</ol>
<h3 id="heading-introducing-discriminatedunion-the-right-tool-for-the-job">Introducing <code>discriminatedUnion</code>: The Right Tool for the Job</h3>
<p>The new <code>discriminatedUnion</code> decoder is designed to address these pain points directly. It leverages the discriminator field (<code>type</code> in our case) to intelligently select and run the correct decoder.</p>
<p><strong>Here's how it works:</strong></p>
<ol>
<li><p>You tell it the name of the <code>discriminatorField</code> (e.g., <code>"type"</code>).</p>
</li>
<li><p>You provide a <code>mapping</code> object where keys are the possible string values of the discriminator (e.g., <code>"user"</code>, <code>"admin"</code>) and values are the corresponding decoders for each variant.</p>
</li>
<li><p>It first decodes <em>only</em> the discriminator field.</p>
</li>
<li><p>Based on the value found, it looks up the correct decoder in your mapping.</p>
</li>
<li><p>It runs <em>only that specific decoder</em> on the original input.</p>
</li>
</ol>
<p>Let's rewrite our <code>Person</code> decoder using <code>discriminatedUnion</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> {
  <span class="hljs-comment">// ... other imports remain the same ...</span>
  discriminatedUnion <span class="hljs-comment">// Import the new decoder</span>
} <span class="hljs-keyword">from</span> <span class="hljs-string">'jsonous'</span>;

<span class="hljs-comment">// userDecoder and adminDecoder definitions remain the same...</span>

<span class="hljs-comment">// --- The "New Way" using discriminatedUnion ---</span>
<span class="hljs-keyword">const</span> personDecoder: Decoder&lt;Person&gt; = discriminatedUnion(<span class="hljs-string">'type'</span>, {
  user: userDecoder, <span class="hljs-comment">// Key matches the 'type' value</span>
  admin: adminDecoder, <span class="hljs-comment">// Key matches the 'type' value</span>
});

<span class="hljs-comment">// Type check: personDecoder is automatically Decoder&lt;Person&gt; - no mapping needed!</span>
</code></pre>
<p>Look how much cleaner that is! No more <code>.map(identity)</code>. The decoder's type <code>Decoder&lt;Person&gt;</code> is inferred automatically from the provided mapping.</p>
<h3 id="heading-why-discriminatedunion-is-better">Why <code>discriminatedUnion</code> is Better</h3>
<p>This new decoder offers significant advantages:</p>
<ol>
<li><p><strong>Type Safety:</strong> Automatically infers the correct union type (<code>User | Admin</code> in this case) without manual type hints in <code>.map</code>.</p>
</li>
<li><p><strong>Conciseness:</strong> Eliminates the repetitive <code>.map(identity)</code> calls, making your decoder definitions cleaner and easier to read.</p>
</li>
<li><p><strong>Clarity:</strong> The structure <code>discriminatedUnion('field', { key1: decoder1, key2: decoder2 })</code> clearly expresses the intent of choosing a decoder based on a specific field's value.</p>
</li>
<li><p><strong>Targeted Errors:</strong> Error messages are much more helpful. Instead of trying all decoders, it fails fast with specific reasons:</p>
<ul>
<li><p>Did the discriminator field (<code>type</code>) exist and was it a string?</p>
</li>
<li><p>Was the value of the discriminator field (<code>"user"</code>, <code>"admin"</code>) one of the expected keys in the mapping?</p>
</li>
<li><p>Did the <em>selected</em> variant decoder (<code>userDecoder</code> or <code>adminDecoder</code>) fail?</p>
</li>
</ul>
</li>
<li><p><strong>Efficiency:</strong> It avoids running unnecessary decoders. It only decodes the simple discriminator field first and then runs exactly one variant decoder.</p>
</li>
</ol>
<h3 id="heading-a-clearer-picture-error-handling">A Clearer Picture: Error Handling</h3>
<p>Let's see the difference in error messages. Consider this invalid input:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> invalidData = { <span class="hljs-keyword">type</span>: <span class="hljs-string">'guest'</span>, id: <span class="hljs-string">'guest-001'</span> };
</code></pre>
<p><strong>Error with</strong> <code>oneOf</code>:</p>
<pre><code class="lang-plaintext">// personDecoderOneOf.decodeAny(invalidData) might produce:
Err: I found the following problems:
Expected user but got "guest":
occurred in a field named 'type'
Expected admin but got "guest":
occurred in a field named 'type'
</code></pre>
<p>(It tells you <em>both</em> decoders failed because the <code>type</code> was wrong).</p>
<p><strong>Error with</strong> <code>discriminatedUnion</code>:</p>
<pre><code class="lang-plaintext">// personDecoder.decodeAny(invalidData) produces:
Err: Unexpected discriminator value 'guest' for field 'type'. Expected one of: user, admin. Found in: {"type":"guest","id":"guest-001"}
</code></pre>
<p>(It tells you <em>exactly</em> the problem: the value <code>"guest"</code> wasn't expected for the <code>type</code> field).</p>
<p>If the <code>type</code> was correct but other data was wrong (e.g., <code>isActive</code> was a string for a <code>user</code>), <code>discriminatedUnion</code> would report the error <em>from within the</em> <code>userDecoder</code>, prefixed clearly:</p>
<pre><code class="lang-plaintext">// Example: { type: 'user', id: 'u1', name: 'Test', isActive: 'yes' }
Err: Error decoding variant with type='user': I expected to find a boolean but instead I found "yes":
occurred in a field named 'isActive'
</code></pre>
<h3 id="heading-get-started-today">Get Started Today!</h3>
<p>Decoding discriminated unions is now simpler, safer, and more efficient in <code>jsonous</code>. If you're working with tagged unions and JSON, the <code>discriminatedUnion</code> decoder is the tool you've been waiting for.</p>
<p>Update <code>jsonous</code> to the latest version and give it a try! We think you'll appreciate the improved ergonomics and clearer error reporting. Check out the README for detailed usage and examples.</p>
<p>Happy Decoding!</p>
]]></content:encoded></item><item><title><![CDATA[What's in the Bag?]]></title><description><![CDATA[Golfers know the phrase "what's in the bag?"—it’s shorthand for talking about the tools they trust when they head out on the course. From drivers to wedges to that one beat-up hybrid they swear by, every club has a role, and every bag tells a story.
...]]></description><link>https://shankproof.dev/whats-in-the-bag</link><guid isPermaLink="true">https://shankproof.dev/whats-in-the-bag</guid><category><![CDATA[TechStack]]></category><category><![CDATA[Deno]]></category><category><![CDATA[fresh]]></category><category><![CDATA[learning]]></category><category><![CDATA[Learning Journey]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Ryan Bell]]></dc:creator><pubDate>Wed, 09 Apr 2025 02:36:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1744165471068/13bc29aa-1059-4fe6-8bd9-f43391444485.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Golfers know the phrase "what's in the bag?"—it’s shorthand for talking about the tools they trust when they head out on the course. From drivers to wedges to that one beat-up hybrid they swear by, every club has a role, and every bag tells a story.</p>
<p>Well, I think developers are a lot like golfers. We each carry a different set of tools. Some we've used for years and know inside and out. Others we’re testing out, seeing how they perform in different conditions. Our tools define our approach—and how we solve problems.</p>
<p>So I’m kicking off a new series on my blog called <strong>What’s In the Bag?</strong> In each post, I’ll explore a specific tech stack or framework, build something small but meaningful with it, and share my honest thoughts along the way. No hype. No gatekeeping. Just real-world experience with a touch of craft and curiosity.</p>
<hr />
<h3 id="heading-first-up-deno-fresh">First Up: Deno + Fresh</h3>
<p>The first stack in the bag is something I’ve been meaning to explore more seriously: <a target="_blank" href="https://deno.com/"><strong>Deno</strong></a> and the <a target="_blank" href="https://fresh.deno.dev/"><strong>Fresh</strong></a> web application framework.</p>
<p>For those unfamiliar, <strong>Deno</strong> is a modern JavaScript/TypeScript runtime created by the original creator of Node.js. It comes with built-in tooling (like testing and linting), strong security defaults, and native TypeScript support out of the box. <strong>Fresh</strong>, built on top of Deno, is a full-stack web framework focused on speed and simplicity. It uses "island architecture" to keep client-side JavaScript minimal by default, enabling ultra-fast performance and a better developer experience.</p>
<p>To test this stack, I’ll be building a <strong>collaborative to-do app</strong>—inspired by a showcase repo from the Deno team that uses <strong>server-sent events (SSE)</strong> to keep browser tabs in sync. The twist? Multiple users can view and update the same task list in real-time. No account system, no database (yet)—just a live playground for learning.</p>
<p>I'll cover:</p>
<ul>
<li><p>Project setup and routing in Fresh</p>
</li>
<li><p>How SSE works and why it's simpler than WebSockets for some use cases</p>
</li>
<li><p>Structuring shared state in a collaborative UI</p>
</li>
<li><p>What felt intuitive, what felt clunky, and what I’d do differently next time</p>
</li>
</ul>
<hr />
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>I’m doing this for a few reasons:</p>
<ul>
<li><p>To <strong>stay sharp</strong> by evaluating new tools</p>
</li>
<li><p>To <strong>create public artifacts</strong> I can point to</p>
</li>
<li><p>To <strong>rebuild the habit</strong> of sharing what I learn</p>
</li>
</ul>
<p>And let’s be honest—like any golfer trying out a new driver, sometimes it’s just fun to see what happens when you take a swing.</p>
<p>If you're curious about Deno, Fresh, or just like hearing how developers think through new stacks, I hope you’ll follow along.</p>
<hr />
<h3 id="heading-join-the-conversation">Join the Conversation</h3>
<p>Have you tried Deno or Fresh? Got a tech tool you think should be in the bag? I’d love to hear what you’re exploring or experimenting with. Drop a comment, tag me on LinkedIn, or share your “what’s in the bag” story.</p>
<p><strong>First build drops later this week.</strong></p>
<p>Thanks for being here.</p>
]]></content:encoded></item><item><title><![CDATA[Why I’m Writing Again: Golf, Code, and Finding My Third Act]]></title><description><![CDATA[Everything old is new again
For the last several years, I’ve been building software quietly behind the scenes. My focus was deep in the architecture, the tooling, and the day-to-day work of helping teams ship reliable code. And while that work has be...]]></description><link>https://shankproof.dev/why-im-writing-again-golf-code-and-finding-my-third-act</link><guid isPermaLink="true">https://shankproof.dev/why-im-writing-again-golf-code-and-finding-my-third-act</guid><category><![CDATA[Software Engineering]]></category><category><![CDATA[golf]]></category><category><![CDATA[golf technology]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Build In Public]]></category><category><![CDATA[Functional Programming]]></category><category><![CDATA[AI]]></category><category><![CDATA[devtools]]></category><dc:creator><![CDATA[Ryan Bell]]></dc:creator><pubDate>Wed, 02 Apr 2025 02:33:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1743560520351/abb56522-9ef7-4f19-a03e-da6d5221bf42.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-everything-old-is-new-again">Everything old is new again</h2>
<p>For the last several years, I’ve been building software quietly behind the scenes. My focus was deep in the architecture, the tooling, and the day-to-day work of helping teams ship reliable code. And while that work has been rewarding, I’ve come to realize I’ve missed something important: sharing what I’ve learned—and exploring what I <em>still</em> want to learn.</p>
<p>So here I am, writing again.</p>
<hr />
<h2 id="heading-a-little-background">A Little Background</h2>
<p>I’ve been a professional software engineer for about 30 years. I’ve built everything from Electronic Medical Records and public health systems to dev tooling and AI-enhanced educational platforms. I’ve worked in big teams, scrappy startups, and everything in between. I’ve led engineering orgs and built systems with 99.999% uptime. I’ve mentored junior devs, contributed to open source (shoutout to JRuby), and maintained libraries like Taskarian and Jsonous that reflect my love for functional programming.</p>
<p>But over time, I stopped writing about it. I got comfortable. Head down, shipping code, helping others get their work across the line.</p>
<p>And honestly, that was fine—until it wasn’t. Because I believe writing makes us sharper. Building in public keeps us honest. And articulating ideas—especially the imperfect, in-progress ones—is how we grow.</p>
<hr />
<h2 id="heading-why-now">Why Now?</h2>
<p>Lately I’ve felt the itch to connect again—to resuscitate those old blogging muscles, not just to teach, but to reflect. I want to document what I’m working on, share what I’m thinking about, and maybe even help a few people along the way.</p>
<p>But this time, I want to do something a little different.</p>
<p>This time, I want to bring golf into the mix.</p>
<hr />
<h2 id="heading-golf-code-really">Golf + Code? Really?</h2>
<p>Yeah. Really.</p>
<p>During COVID, I developed a deep love of golf. I’m no pro, but I’ve spent enough time on the course—and building tools <em>about</em> the course—to know there’s something fascinating in the overlap between software and the swing. Systems thinking. Feedback loops. Precision. Resets. Debugging under pressure.</p>
<p>And lately I’ve started building apps for golfers. Tools like Yardage Card and StrokeSheet—apps that help players understand their own game the way developers debug production systems. I’ll write about those projects here, along with the libraries, design decisions, and technical rabbit holes they lead me down.</p>
<hr />
<h2 id="heading-what-to-expect">What to Expect</h2>
<p>This blog will be a mix of:</p>
<ul>
<li><p>Real-world software engineering thoughts and practices</p>
</li>
<li><p>Functional programming and Typescript nerdery</p>
</li>
<li><p>Developer productivity tools (including AI-assisted workflows)</p>
</li>
<li><p>Golf tech, stats, and systems</p>
</li>
<li><p>Maybe the occasional metaphorical crossover—because debugging a smother hook feels a lot like debugging a bug</p>
</li>
</ul>
<p>If that sounds like your kind of thing, I hope you’ll stick around. I’m writing this for people who care about craft, who like to build, and who maybe—just maybe—believe that thoughtful systems can make everything a little more fun.</p>
<p>Thanks for being here. Let’s see where this goes.</p>
]]></content:encoded></item></channel></rss>