<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aejkatappaja</title>
    <description>The latest articles on DEV Community by Aejkatappaja (@aejkatappaja).</description>
    <link>https://dev.to/aejkatappaja</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3868651%2F5274c86f-3dc8-4ab1-833b-ac25183b6411.png</url>
      <title>DEV Community: Aejkatappaja</title>
      <link>https://dev.to/aejkatappaja</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aejkatappaja"/>
    <language>en</language>
    <item>
      <title>My CI was green. At 150 connections, Postgres said no.</title>
      <dc:creator>Aejkatappaja</dc:creator>
      <pubDate>Wed, 08 Jul 2026 08:58:08 +0000</pubDate>
      <link>https://dev.to/aejkatappaja/i-load-tested-my-side-project-and-postgres-said-no-33h0</link>
      <guid>https://dev.to/aejkatappaja/i-load-tested-my-side-project-and-postgres-said-no-33h0</guid>
      <description>&lt;p&gt;I built a small training log in Go called &lt;a href="https://github.com/Aejkatappaja/go-gym" rel="noopener noreferrer"&gt;go-gym&lt;/a&gt;. Two front doors over one backend: a JSON API and a server-rendered HTMX UI. Nothing exotic. It had been sitting there with a green CI for weeks: build passes, tests pass, lint passes.&lt;/p&gt;

&lt;p&gt;Last week I added a &lt;code&gt;/metrics&lt;/code&gt; endpoint, got curious about the numbers behind it, and pointed a load generator at the dashboard to see the p95.&lt;/p&gt;

&lt;p&gt;It didn't get slow. It fell over.&lt;/p&gt;

&lt;p&gt;The setup was &lt;code&gt;hey&lt;/code&gt;, 150 concurrent connections, 15 seconds, against &lt;code&gt;GET /app&lt;/code&gt;. The dashboard is the heaviest read in the app: a stats aggregate, a page of workouts, and the activity heatmap counts. I expected mediocre latency. Instead the worst request took 13.5 seconds and a big chunk of them came back as errors.&lt;/p&gt;

&lt;p&gt;The server log said why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: sorry, too many clients already (SQLSTATE 53300)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres was refusing connections. 321 of them across the run.&lt;/p&gt;

&lt;p&gt;The part I'd never thought about was the database setup, which was one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pgx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;database/sql&lt;/code&gt; hands you a connection pool for free, and I'd been quietly assuming it was sensible out of the box. It isn't. &lt;code&gt;SetMaxOpenConns&lt;/code&gt; defaults to zero, and zero means unlimited. So under load, every query that can't grab an idle connection just opens a new one. At 150 concurrent requests each running a few queries, the pool opened connection after connection until it walked straight into Postgres's &lt;code&gt;max_connections&lt;/code&gt;, which is 100 by default. Past that, Postgres says no, and the handler returns a 500.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/metrics&lt;/code&gt; endpoint I'd added the day before turned out to be useful, not as the thing under test but as the instrument. I could watch the latencies climb while the error rate went up. The bottleneck wasn't the query. It wasn't a missing index, which is what I'd have guessed if you woke me up and asked. It was that I was opening hundreds of connections to a server that wanted a hundred.&lt;/p&gt;

&lt;p&gt;The fix is four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetMaxOpenConns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetMaxIdleConns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetConnMaxIdleTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Minute&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetConnMaxLifetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hour&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;25 sits well under Postgres's 100, with room to spare for migrations, a background job, and anything else holding a connection. The first line is the one that matters. Once the pool is capped, extra requests don't open new connections. They wait for one to free up. The queue moves to the client side, where waiting is cheap, instead of the database side, where it's fatal.&lt;/p&gt;

&lt;p&gt;You might be reaching for the reply: just use &lt;code&gt;pgxpool&lt;/code&gt;. Fair. Its native pool defaults to a bounded size, so it wouldn't have blown up this way out of the box. But the store and the migrations (goose) are both built on &lt;code&gt;database/sql&lt;/code&gt;, so switching isn't free, and you'd still have to size the pool for your database either way. The point isn't which pool you pick. It's that you have to know its default and set the bound. &lt;code&gt;database/sql&lt;/code&gt; just happens to ship the dangerous default.&lt;/p&gt;

&lt;p&gt;Same load test after the change:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;GET /app&lt;/code&gt;, c=150, 15s&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;failed requests&lt;/td&gt;
&lt;td&gt;321&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 latency&lt;/td&gt;
&lt;td&gt;requests were erroring out&lt;/td&gt;
&lt;td&gt;32 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 latency&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;92 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;throughput&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;~8,400 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before the fix, the worst request sat at 13.5 seconds before it gave up. After, the slowest 1% came back inside 92 milliseconds and nothing failed. One run, same machine, same dataset, so it's a clean comparison of the one thing I changed.&lt;/p&gt;

&lt;p&gt;Let me be honest about what this is. It's &lt;code&gt;hey&lt;/code&gt; on my laptop against a local Postgres with a small dataset, so the absolute numbers don't mean much. What's real is the failure mode. An unbounded pool will exhaust any connection-limited database the moment your concurrency is higher than the number of slots the database has, and it picks the worst possible time to do it, which is when you have traffic.&lt;/p&gt;

&lt;p&gt;Two things stuck with me.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;go build&lt;/code&gt; and my CI were green the entire time. Compiling tells you the types line up. It tells you nothing about what happens at 150 connections. The only reason I found this is that I ran the thing under load, which I did almost by accident.&lt;/p&gt;

&lt;p&gt;And I would have guessed wrong. My reflex for "slow under load" is "add an index" or "there's an N+1 in there somewhere." The queries were fine. The pool was the problem, and I only knew that because the error said so, in plain words, in the log line up above. Measure the actual thing. Your instinct is a hypothesis, not an answer.&lt;/p&gt;

&lt;p&gt;The four lines live in &lt;a href="https://github.com/Aejkatappaja/go-gym/blob/main/internal/store/database.go" rel="noopener noreferrer"&gt;&lt;code&gt;database.go&lt;/code&gt;&lt;/a&gt;, and there's a &lt;a href="https://github.com/Aejkatappaja/go-gym/blob/main/scripts/loadtest.sh" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/loadtest.sh&lt;/code&gt;&lt;/a&gt; in the repo if you want to reproduce the run. The whole project is at &lt;a href="https://github.com/Aejkatappaja/go-gym" rel="noopener noreferrer"&gt;github.com/Aejkatappaja/go-gym&lt;/a&gt;. Set your pool limits. The default is a trap, and it stays quiet right up until it isn't.&lt;/p&gt;

</description>
      <category>go</category>
      <category>postgres</category>
      <category>performance</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your design system's Shadow DOM breaks skeleton loaders. Here's the fix</title>
      <dc:creator>Aejkatappaja</dc:creator>
      <pubDate>Tue, 07 Jul 2026 09:55:02 +0000</pubDate>
      <link>https://dev.to/aejkatappaja/your-design-systems-shadow-dom-breaks-skeleton-loaders-heres-the-fix-4de7</link>
      <guid>https://dev.to/aejkatappaja/your-design-systems-shadow-dom-breaks-skeleton-loaders-heres-the-fix-4de7</guid>
      <description>&lt;p&gt;If you build with a Web Component design system (Stencil, Lit, FAST, or anything built on them like Ionic and Shoelace), skeleton loaders have a quiet failure mode: the content you want to measure isn't in the light DOM. It's inside a shadow root.&lt;/p&gt;

&lt;p&gt;Say a row in your list is &lt;code&gt;&amp;lt;user-card&amp;gt;&lt;/code&gt;. From the outside, that element looks nearly empty: the avatar, name, and bio live inside its shadow root, projected through slots. A skeleton tool that measures the light DOM walks in, finds no leaf elements to trace, and gives you a blank or collapsed placeholder. The skeleton doesn't match because the tool literally can't see the real layout.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it's hard?
&lt;/h3&gt;

&lt;p&gt;Shadow DOM is an encapsulation boundary on purpose. &lt;code&gt;querySelectorAll&lt;/code&gt; from the document doesn't cross it. &lt;code&gt;getComputedStyle&lt;/code&gt; stops at the host. And slotted content is even trickier: a &lt;code&gt;&amp;lt;slot&amp;gt;&lt;/code&gt; renders light-DOM children at a position defined inside the shadow tree, so where a node is in the DOM and where it paints are different places.&lt;/p&gt;

&lt;p&gt;To generate an accurate skeleton for these components you have to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Detect that a slotted element has an open shadow root&lt;/li&gt;
&lt;li&gt;Walk into that root instead of stopping at the host&lt;/li&gt;
&lt;li&gt;Resolve &lt;code&gt;&amp;lt;slot&amp;gt;&lt;/code&gt; elements to the nodes actually projected into them&lt;/li&gt;
&lt;li&gt;Measure the real painted leaves, wherever they live&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;phantom-ui&lt;/code&gt; does this behind one attribute, &lt;code&gt;pierce-shadow&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;phantom&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;ui&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="nx"&gt;pierce&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;shadow&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/user-card&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/user-card&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/user-card&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/phantom-ui&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;pierce-shadow&lt;/code&gt; on, it descends into each open shadow root follows the slots to their projected content, and measures the actual leaf elements inside. &lt;br&gt;
The shimmer blocks land on the real avatar, the real heading, the real text lines, even though all of it is encapsulated.&lt;br&gt;
Same structure-aware skeletons you'd get for plain markup, now for your component library.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbce8lwf7k964j2kp4o2r.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbce8lwf7k964j2kp4o2r.gif" alt="pierce-shadow demo phantom-ui" width="720" height="540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One honest limit: this works on open shadow roots only. A closed shadow root is invisible to everything outside it by design, including this, so there's nothing to measure. Open roots are the common default, so most design systems are covered.&lt;/p&gt;

&lt;p&gt;It composes with the rest of the library, so the same component also does the refresh/overlay mode and works across React, Vue, Svelte, Angular, Solid, Qwik and vanilla. &lt;br&gt;
If you want the how-it-works on the core DOM-measurement technique, that's in the &lt;a href="https://dev.to/aejkatappaja/i-built-a-web-component-that-generates-skeleton-loaders-from-your-real-dom-59ae"&gt;original write-up&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @aejkatappaja/phantom-ui
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Or via CDN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://unpkg.com/@aejkatappaja/phantom-ui/dist/phantom-ui.cdn.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Links:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Aejkatappaja/phantom-ui" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aejkatappaja.github.io/phantom-ui/demo" rel="noopener noreferrer"&gt;Interactive demo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aejkatappaja.github.io/phantom-ui/" rel="noopener noreferrer"&gt;Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://stackblitz.com/github/Aejkatappaja/phantom-ui-stencil-demo" rel="noopener noreferrer"&gt;StackBlitz Playground (Stencil)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your rows are custom elements and your skeletons never looked right, flip &lt;code&gt;pierce-shadow&lt;/code&gt; on.&lt;/p&gt;

</description>
      <category>webcomponents</category>
      <category>designsystem</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>End-to-end type safety between Go and TypeScript</title>
      <dc:creator>Aejkatappaja</dc:creator>
      <pubDate>Mon, 29 Jun 2026 12:45:20 +0000</pubDate>
      <link>https://dev.to/aejkatappaja/end-to-end-type-safety-between-go-and-typescript-10mf</link>
      <guid>https://dev.to/aejkatappaja/end-to-end-type-safety-between-go-and-typescript-10mf</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00nzq3fsxpznp91xaszu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F00nzq3fsxpznp91xaszu.png" alt="Go gopher holding TypeScript logo" width="200" height="230"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You change a field on a Go struct. Three days later a TypeScript component reads that field, still expecting the old shape, and the bug ships. The compiler on each side was happy. The two compilers just never talked to each other.&lt;/p&gt;

&lt;p&gt;This is the gap every Go plus TypeScript codebase has to close somehow. There are five common ways to close it. This post builds the same small app five times, one per approach, and compares what each one actually buys you. Every code sample comes from a &lt;a href="https://github.com/Aejkatappaja/go-ts-types" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; where all five run.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "type-safe" has to mean
&lt;/h2&gt;

&lt;p&gt;"Generate types from the backend" is the easy 80 percent. The interesting part is the 20 percent where the two type systems disagree about what a value even is. So the demo app, a task tracker, is built around the cases that break pipelines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A closed enum, &lt;code&gt;TaskStatus&lt;/code&gt;, whose wire zero value is not a valid state.&lt;/li&gt;
&lt;li&gt;A nullable field, &lt;code&gt;assignee&lt;/code&gt;, sitting next to an optional field, &lt;code&gt;dueDate&lt;/code&gt;. One can be present and null, the other can be absent. Most pipelines collapse the two.&lt;/li&gt;
&lt;li&gt;An identifier that is a &lt;code&gt;string&lt;/code&gt; on the wire but must not be mixed up with any other &lt;code&gt;string&lt;/code&gt; in the program.&lt;/li&gt;
&lt;li&gt;Cursor pagination, where "no more pages" is a different value from "empty page".&lt;/li&gt;
&lt;li&gt;A typed not-found error, so the client branches on a known error and not on an HTTP status code it pattern-matched by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any approach can serialize a struct. The question is what happens to these five cases when they cross the language boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reframe: transport types are not domain types
&lt;/h2&gt;

&lt;p&gt;Here is the idea the rest of the post hangs on, and the one most comparisons skip.&lt;/p&gt;

&lt;p&gt;Code generators give you transport types. A generated ID is a plain &lt;code&gt;string&lt;/code&gt;. A generated enum carries a zero value that means nothing. A generated optional field cannot tell you whether the value was null or simply absent. That is correct for a transport: it describes bytes on the wire. It is not what you want to write business logic against.&lt;/p&gt;

&lt;p&gt;What you want is domain types. An ID that the compiler refuses to confuse with a different ID. An enum with no invalid member. A value that has already been parsed and can be trusted without re-checking.&lt;/p&gt;

&lt;p&gt;So in the repo the domain lives in exactly one place, with no transport in it at all, and each approach maps its generated types onto that domain through a thin adapter. The cost and shape of that adapter, per approach, is the actual comparison.&lt;/p&gt;

&lt;h3&gt;
  
  
  The shared Go domain
&lt;/h3&gt;

&lt;p&gt;Go has nominal typing already, so branded identifiers are just distinct types with a single sanctioned constructor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="n"&gt;UserID&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
 &lt;span class="n"&gt;ProjectID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
 &lt;span class="n"&gt;TaskID&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// ParseUserID is the only way in. A raw string will not compile where UserID is&lt;/span&gt;
&lt;span class="c"&gt;// expected, and a malformed one never becomes a UserID at all.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ParseUserID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;parseID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;UserID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The enum makes its unknown value explicit instead of leaning on the Go zero value.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;TaskStatus&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
 &lt;span class="n"&gt;StatusUnknown&lt;/span&gt; &lt;span class="n"&gt;TaskStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;iota&lt;/span&gt; &lt;span class="c"&gt;// zero value is not a real state, on purpose&lt;/span&gt;
 &lt;span class="n"&gt;StatusTodo&lt;/span&gt;
 &lt;span class="n"&gt;StatusInProgress&lt;/span&gt;
 &lt;span class="n"&gt;StatusDone&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ParseTaskStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TaskStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;wireToStatus&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;StatusUnknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"task status: unknown %q"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;StatusUnknown&lt;/code&gt; at position zero is the whole game. If todo were the zero value, any field that was never set would read as todo, and nothing would tell you. The nullable versus optional distinction is carried by pointers, where a nil &lt;code&gt;*UserID&lt;/code&gt; is "no assignee" and a nil &lt;code&gt;*time.Time&lt;/code&gt; is "no due date set".&lt;/p&gt;

&lt;h3&gt;
  
  
  The shared TypeScript domain
&lt;/h3&gt;

&lt;p&gt;On the TypeScript side the same domain is expressed with zod, and the schema is the parser. This is parse don't validate: you parse untrusted input into a precise type once, at the edge, and downstream code receives the narrow type and never re-checks it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idPattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z&lt;/span&gt;&lt;span class="se"&gt;]{2,5}&lt;/span&gt;&lt;span class="sr"&gt;_&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9A-HJKMNP-TV-Z&lt;/span&gt;&lt;span class="se"&gt;]{26}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idPattern&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UserId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;UserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;UserId&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TaskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProjectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TaskStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// z.enum(["todo", "in_progress", "done"])&lt;/span&gt;
  &lt;span class="na"&gt;assignee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// present, value may be null&lt;/span&gt;
  &lt;span class="na"&gt;dueDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// key may be absent entirely&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.brand&amp;lt;"UserId"&amp;gt;()&lt;/code&gt; is the TypeScript equivalent of Go's distinct type. At runtime it is still a string. At compile time, a plain string no longer fits where a &lt;code&gt;UserId&lt;/code&gt; is wanted, so you cannot pass a project id to a function expecting a user id. &lt;br&gt;
With &lt;code&gt;exactOptionalPropertyTypes&lt;/code&gt; on, &lt;code&gt;nullable()&lt;/code&gt; and &lt;code&gt;optional()&lt;/code&gt; are genuinely different types, and the schema keeps them apart instead of melting both into &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The page envelope carries the fifth case, cursor pagination, and it is the same shape everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TaskPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;nextCursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// null means there is no next page&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The typing point is the &lt;code&gt;null&lt;/code&gt;. A &lt;code&gt;nextCursor&lt;/code&gt; of &lt;code&gt;null&lt;/code&gt; is "there are no more pages", which is a different state from an empty &lt;code&gt;items&lt;/code&gt; array, and different again from the cursor being absent on the request. If &lt;code&gt;nextCursor&lt;/code&gt; were typed as a plain &lt;code&gt;string&lt;/code&gt;, the end-of-list case would be an empty string the client has to remember&lt;br&gt;
to test for, which is exactly the kind of in-band signal that leaks. Each approach has to preserve that nullable on the way across: Connect uses an &lt;code&gt;optional&lt;/code&gt; proto field the adapter maps to null, the OpenAPI document marks it &lt;code&gt;nullable&lt;/code&gt;, GraphQL makes it a nullable &lt;code&gt;String&lt;/code&gt;. The store returns a &lt;code&gt;*string&lt;/code&gt; cursor for the same&lt;br&gt;
reason, so "no more pages" is a nil pointer rather than a sentinel value.&lt;/p&gt;
&lt;h3&gt;
  
  
  Branding and runtime validation are two different decisions
&lt;/h3&gt;

&lt;p&gt;It is worth separating two things the rest of this post keeps distinct, because conflating them leads to bad conclusions later.&lt;/p&gt;

&lt;p&gt;A brand is a compile-time idea. A runtime check is a runtime cost. There is one way to attach a brand with zero runtime cost, and that is a cast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;UserId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// do not do this&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This post never does that, and you should not either. A cast is an unchecked assertion. It tells the compiler "trust me" about a value that came off a wire, which is exactly the drift the brand was supposed to prevent. The brand now lies: the type says &lt;code&gt;UserId&lt;/code&gt;, the value was never checked to be one.&lt;/p&gt;

&lt;p&gt;So the only honest way to obtain a branded value is to parse it through the schema, which validates and brands in the same step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseUserId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// validates the format, returns a UserId, or throws&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means runtime validation is not redundant overhead bolted onto branding. It is the price of earning the brand without cheating.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do you own both ends?
&lt;/h3&gt;

&lt;p&gt;Before any tool, there is one architectural decision that changes the whole answer:&lt;br&gt;
do you trust the producer? If the same team ships the Go server and the TS client, deploys them together, and the contract is generated from one source, then the wire is effectively trusted and the generated types already guarantee the structure.&lt;br&gt;
Re-parsing every response with a second schema is mostly belt and suspenders, and the interesting move is to keep the validation in the source of truth (proto with protovalidate, a struct tag, a schema constraint) rather than hand maintaining a second one. If the producer is outside your control, a public API, a third party,&lt;br&gt;
an older deployed version, then the wire is untrusted, the generated type is just a claim, and parsing at the edge earns its keep. The same five approaches below sit differently depending on which world you are in, so decide this first. The brand is not the question; the trust is.&lt;/p&gt;

&lt;p&gt;Keep these two files in mind. Every approach below is judged on how much work it takes to get from its generated output back to these types.&lt;/p&gt;
&lt;h2&gt;
  
  
  Approach 1: ConnectRPC
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;.proto&lt;/code&gt; file is the source of truth. &lt;code&gt;buf generate&lt;/code&gt; produces a Go server stub and TypeScript message types from it, so the contract cannot drift between the two languages.&lt;/p&gt;

&lt;p&gt;The proto bakes in two of our hard cases on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight protobuf"&gt;&lt;code&gt;&lt;span class="kd"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;TaskStatus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;TASK_STATUS_UNSPECIFIED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the proto analogue of StatusUnknown&lt;/span&gt;
  &lt;span class="na"&gt;TASK_STATUS_TODO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;TASK_STATUS_IN_PROGRESS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="na"&gt;TASK_STATUS_DONE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;message&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="na"&gt;project_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;TaskStatus&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;optional&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="na"&gt;assignee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                 &lt;span class="c1"&gt;// null in the domain&lt;/span&gt;
  &lt;span class="k"&gt;optional&lt;/span&gt; &lt;span class="n"&gt;google.protobuf.Timestamp&lt;/span&gt; &lt;span class="na"&gt;due_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// absent in the domain&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that proto3 has one presence mechanism, so &lt;code&gt;assignee&lt;/code&gt; and &lt;code&gt;due_date&lt;/code&gt; are both &lt;code&gt;optional&lt;/code&gt;. The wire format cannot tell null from absent. That limitation becomes the adapter's job on both ends.&lt;/p&gt;

&lt;p&gt;On the server, the generated handler interface is satisfied by a struct, and a compile-time assertion guarantees it. Rename an rpc and the build breaks before anything ships:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="n"&gt;tasktrackerv1connect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaskTrackerServiceHandler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request field is parsed into the domain before the store sees it, and the domain not-found error is mapped to a Connect code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;GetTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tasktrackerv1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetTaskRequest&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tasktrackerv1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetTaskResponse&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseTaskID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Msg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetId&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invalidArg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// CodeInvalidArgument&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;
 &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toConnectError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// ErrNotFound -&amp;gt; CodeNotFound&lt;/span&gt;
 &lt;span class="p"&gt;}&lt;/span&gt;
 &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tasktrackerv1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetTaskResponse&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;toProtoTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)}),&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated TypeScript types show the limitation directly. The proto compiler emits &lt;code&gt;assignee?: string&lt;/code&gt; and &lt;code&gt;dueDate?: Timestamp&lt;/code&gt;, both optional, because that is all proto presence can say. The client adapter reconstructs the domain distinction and validates the result through the branded schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toDomainTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProtoTask&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;wireStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// throws on UNSPECIFIED&lt;/span&gt;
    &lt;span class="na"&gt;assignee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assignee&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// absent becomes null&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dueDate&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dueDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestampDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dueDate&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// absent stays absent&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;DomainTask&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// untrusted wire -&amp;gt; branded domain&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Connect error code is translated back into the same domain error the server raised, so the caller writes &lt;code&gt;catch (e) { if (isNotFound(e)) ... }&lt;/code&gt; and never touches a status code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;notFoundOr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;never&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ConnectError&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;Code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NotFoundError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;DomainTask.parse&lt;/code&gt; call above is the hand-written zod option, and it makes sense here only if you treat the server as untrusted. When you own both ends and deploy them together, proto already guarantees the structure, so re-parsing the whole payload with a second schema you maintain by hand is mostly wasted work. But recall the earlier point: you still cannot brand with a cast. So what carries the brand if not a hand-written schema?&lt;/p&gt;

&lt;p&gt;The schema-driven answer is &lt;code&gt;protovalidate&lt;/code&gt;. You put the constraints in the proto, next to the field they describe, and generate validators for both languages from that single source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight protobuf"&gt;&lt;code&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;buf.validate.field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^[a-z]{2,5}_[0-9A-HJKMNP-TV-Z]{26}$"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;protovalidate-go&lt;/code&gt; enforces it server side and &lt;code&gt;protovalidate-es&lt;/code&gt; enforces it in the browser, both from the same rule. The constraint lives in the source of truth rather than in a zod file that can drift from the proto. This is the cleaner way to satisfy parse don't validate in the Connect world: validate from the schema, then brand the validated value, and keep the hand-written zod for the cases where the producer is genuinely outside your control.&lt;/p&gt;

&lt;p&gt;What Connect gets right: one contract for two languages, generated service interfaces with compile-time enforcement, first-class typed errors through status codes, schema-driven runtime validation through protovalidate, and streaming when you need it. What it costs: a proto toolchain (&lt;code&gt;buf&lt;/code&gt;), a build step, and an adapter layer to turn transport types into domain types, because the generated IDs are plain strings and the generated enum has that unspecified zero value. For greenfield RPC between a Go service and a TS client, this is the strongest default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 2: OpenAPI, spec first
&lt;/h2&gt;

&lt;p&gt;Here an OpenAPI document is the source of truth, written by hand or assembled from components, and both sides generate from it: &lt;code&gt;oapi-codegen&lt;/code&gt; for the Go server, &lt;code&gt;openapi-typescript&lt;/code&gt; plus &lt;code&gt;openapi-fetch&lt;/code&gt; for the TS client.&lt;/p&gt;

&lt;p&gt;The defining trait is that the spec is a real artifact you own and review, not a byproduct of code. You can express &lt;code&gt;nullable&lt;/code&gt; and &lt;code&gt;required&lt;/code&gt; precisely, which maps better onto our null versus absent case than proto does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;object&lt;/span&gt;
  &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;projectId&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;assignee&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;assignee&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
      &lt;span class="na"&gt;nullable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# present, may be null&lt;/span&gt;
    &lt;span class="na"&gt;dueDate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
      &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;date-time&lt;/span&gt; &lt;span class="c1"&gt;# not in `required`, so may be absent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;openapi-typescript&lt;/code&gt; turns that into &lt;code&gt;assignee: string | null&lt;/code&gt; and &lt;code&gt;dueDate?: string&lt;/code&gt;, so the distinction survives generation, which is one fewer thing the adapter has to rebuild than in the proto case. The enum still generates as a bare string union and the IDs still generate as &lt;code&gt;string&lt;/code&gt;, so the branded domain layer is the same idea: parse the generated response through the zod schemas at the boundary.&lt;/p&gt;

&lt;p&gt;One version gotcha worth knowing in 2026: &lt;code&gt;oapi-codegen&lt;/code&gt; does not yet support the OpenAPI 3.1 nullable union (&lt;code&gt;type: [string, "null"]&lt;/code&gt;), so the document above is pinned to 3.0.3 and uses &lt;code&gt;nullable: true&lt;/code&gt;. &lt;code&gt;openapi-typescript&lt;/code&gt; handles 3.1 fine, so the limitation is on the Go side, and the snippet above is exactly what the companion repo ships.&lt;/p&gt;

&lt;p&gt;What it gets right: the spec is the contract, it is readable, and it doubles as documentation and as the thing your public API consumers generate their own clients from. What it costs: writing and maintaining the document, and trusting that the server actually conforms to it, which &lt;code&gt;oapi-codegen&lt;/code&gt; helps with by generating a strict server interface from the same spec, so a handler that returns the wrong shape fails to compile. Best fit when the API is REST shaped or public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 3: OpenAPI, code first
&lt;/h2&gt;

&lt;p&gt;Same destination, opposite direction. The Go code is the source of truth and the OpenAPI document is derived from it, then the TypeScript side generates from that document exactly as before. There are two generations of tooling here, and they are not close in quality, so it is worth treating them separately.&lt;/p&gt;

&lt;p&gt;The old way is comment driven. A tool like &lt;code&gt;swaggo&lt;/code&gt; reads annotations above the handler and assembles a spec from them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// @Summary Get a task&lt;/span&gt;
&lt;span class="c"&gt;// @Param   id  path     string true "Task ID"&lt;/span&gt;
&lt;span class="c"&gt;// @Success 200 {object} TaskResponse&lt;/span&gt;
&lt;span class="c"&gt;// @Failure 404 {object} ErrorResponse&lt;/span&gt;
&lt;span class="c"&gt;// @Router  /tasks/{id} [get]&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;GetTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;echo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the version that earns code first its bad reputation. The spec is only as precise as the comments, the type information survives a round trip through free text, and nullable versus optional comes down to annotation discipline rather than anything the compiler checks. The comments drift from the types they describe and nothing fails when they do. In fairness, a team that fails CI when the regenerated spec no longer matches the handlers can keep this honest, and some run it that way for years. But then it is the pipeline holding the contract, not the compiler, which is the same fragility the manual approach has.&lt;/p&gt;

&lt;p&gt;The modern way, in 2026, does not use comments at all. Libraries like &lt;code&gt;Huma&lt;/code&gt; and &lt;code&gt;Fuego&lt;/code&gt; derive the OpenAPI document from real Go structs and generics, so the input and output types of a handler are the spec:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;idInput&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`path:"id"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Assignee is a pointer with no omitempty, so Huma marks it required and nullable.&lt;/span&gt;
&lt;span class="c"&gt;// DueDate uses omitempty, so Huma marks it optional. The struct is the schema.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;taskBody&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="n"&gt;ID&lt;/span&gt;        &lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"id"`&lt;/span&gt;
 &lt;span class="n"&gt;ProjectID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"projectId"`&lt;/span&gt;
 &lt;span class="n"&gt;Title&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"title"`&lt;/span&gt;
 &lt;span class="n"&gt;Status&lt;/span&gt;    &lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"status" enum:"todo,in_progress,done"`&lt;/span&gt;
 &lt;span class="n"&gt;Assignee&lt;/span&gt;  &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;    &lt;span class="s"&gt;`json:"assignee"`&lt;/span&gt;
 &lt;span class="n"&gt;DueDate&lt;/span&gt;   &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt; &lt;span class="s"&gt;`json:"dueDate,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;taskOutput&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="n"&gt;Body&lt;/span&gt; &lt;span class="n"&gt;taskBody&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;huma&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;huma&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Operation&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
 &lt;span class="n"&gt;OperationID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"getTask"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;Method&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;      &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MethodGet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;"/tasks/{id}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getTask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// func(ctx, *idInput) (*taskOutput, error), parses the id into the domain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spec cannot lag the implementation, because there is no separate document and no comment layer: the spec is a projection of the Go type system. Request validation comes from struct tags and runs before the handler, and errors follow RFC 7807 by default (the handler returns &lt;code&gt;huma.Error404NotFound&lt;/code&gt; for the domain not-found sentinel), which gives the TypeScript side a typed error shape to generate against. Nullable versus optional is now expressed in Go types and field tags rather than prose, so it is as precise as your structs are.&lt;/p&gt;

&lt;p&gt;What it gets right, with Huma or Fuego: a single source of truth that is real Go code, a spec that is generated rather than written, runtime validation derived from the same types, and typed errors out of the box. What it costs: you are coupled to the framework's way of describing handlers, and you have less direct control over the emitted schema than when you author the document by hand. Pick the modern libraries over the comment based tools without hesitation. Code first stopped meaning magic comments a while ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 4: GraphQL
&lt;/h2&gt;

&lt;p&gt;A GraphQL schema is the source of truth. &lt;code&gt;gqlgen&lt;/code&gt; generates Go resolvers from it, &lt;code&gt;graphql-codegen&lt;/code&gt; generates TypeScript types and typed operations from it and from your queries.&lt;/p&gt;

&lt;p&gt;GraphQL is the one approach with native support for branded types end to end, through custom scalars. You declare a scalar in the schema and map it to a branded type on each side, so the generated client already hands you a &lt;code&gt;UserId&lt;/code&gt; rather than a &lt;code&gt;string&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;scalar&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UserId&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="k"&gt;scalar&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TaskId&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="k"&gt;scalar&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TaskId&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;assignee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UserId&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# nullable by default&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;dueDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# nullable by default&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TaskStatus&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// graphql-codegen config&lt;/span&gt;
&lt;span class="nx"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;UserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@gotstypes/domain#UserId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;TaskId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@gotstypes/domain#TaskId&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is genuinely less adapter code than the RPC and REST approaches, because the nominal types come straight out of generation: &lt;code&gt;getTask().id&lt;/code&gt; is typed as a &lt;code&gt;TaskId&lt;/code&gt;. The honest caveat is that this branding is a compile-time assertion the codegen makes from the scalar config, not a runtime check, which is the same trust gap as a cast. So the companion repo still parses each response through the domain schema for runtime safety. The win is less boilerplate and branded types by default, not validation. It also rests on the &lt;code&gt;scalars&lt;/code&gt; block staying in sync with the domain types: nothing stops that mapping from drifting, so the safety is only as good as the config is maintained.&lt;/p&gt;

&lt;p&gt;The cost is the rest of GraphQL: nullability is per field and defaults to nullable, so you spend &lt;code&gt;!&lt;/code&gt; discipline everywhere; the runtime is heavier; and you take on resolver wiring, n plus one concerns, and a query language your frontend now has to learn. The null versus absent case bends too: GraphQL has only null in its output, no notion of absent, so the optional &lt;code&gt;dueDate&lt;/code&gt; comes back as &lt;code&gt;string | null&lt;/code&gt; and the client maps a null back to absent to line up with the domain. Worth it when the data is genuinely graph shaped and many clients want different slices of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 5: manual types with a runtime parser
&lt;/h2&gt;

&lt;p&gt;No code generation. The Go side writes its JSON DTOs by hand, and the TypeScript side reuses the shared zod schema as both the type and the validator, parsing every response at the boundary. This is the baseline the others are measured against.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/tasks/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the shared domain schema: branded, validated, or it throws&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it gets right: zero toolchain, zero build step, full control, and &lt;code&gt;.brand()&lt;/code&gt; gives you branded types natively with no adapter at all, because the schema is already the domain. Runtime validation is built in rather than bolted on. What it costs: the contract is a promise, not a guarantee. Nothing connects the Go struct to the zod schema, so the two drift the moment someone forgets. It works for a small surface or a spike, and stops scaling the day the API grows faster than your discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TypeScript-only tools: tRPC and Pothos
&lt;/h2&gt;

&lt;p&gt;Two names come up constantly in this space, and both assume a TypeScript backend, so neither fits a Go one. They are worth a direct answer.&lt;/p&gt;

&lt;p&gt;People search for "tRPC with Go" constantly, and the answer is you cannot. tRPC achieves its type safety by sharing the actual TypeScript types between client and server through a TypeScript import. There is no schema and no code generation,&lt;br&gt;
which is exactly why it is frictionless, and exactly why it is TypeScript on both ends only. A Go backend has no TypeScript types to import. If you reached for this post hoping to wire tRPC to Go, the closest thing in spirit is ConnectRPC: schema first, generated clients, typed errors, and a similar end-to-end feel.&lt;/p&gt;

&lt;p&gt;Pothos is the same story from the GraphQL side. It is a code-first GraphQL schema builder with excellent type inference, but it builds the schema in TypeScript, so it fills the role gqlgen fills in this post, just for a Node backend instead of a Go one. If your backend were TypeScript, Pothos would be a strong way to define the&lt;br&gt;
schema, and the client would still generate from it with graphql-codegen exactly as shown above. With a Go backend the schema comes from gqlgen, and Pothos does not enter the picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scorecard
&lt;/h2&gt;

&lt;p&gt;Code first below means the modern struct based libraries (Huma, Fuego), not the&lt;br&gt;
comment based tools.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Connect&lt;/th&gt;
&lt;th&gt;OpenAPI spec-first&lt;/th&gt;
&lt;th&gt;OpenAPI code-first&lt;/th&gt;
&lt;th&gt;GraphQL&lt;/th&gt;
&lt;th&gt;Manual&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Source of truth&lt;/td&gt;
&lt;td&gt;proto&lt;/td&gt;
&lt;td&gt;the document&lt;/td&gt;
&lt;td&gt;Go structs&lt;/td&gt;
&lt;td&gt;schema&lt;/td&gt;
&lt;td&gt;nothing shared&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contract enforced&lt;/td&gt;
&lt;td&gt;compile time&lt;/td&gt;
&lt;td&gt;generation&lt;/td&gt;
&lt;td&gt;Go type system&lt;/td&gt;
&lt;td&gt;generation&lt;/td&gt;
&lt;td&gt;by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Null vs absent&lt;/td&gt;
&lt;td&gt;adapter rebuilds it&lt;/td&gt;
&lt;td&gt;expressed in spec&lt;/td&gt;
&lt;td&gt;Go types and tags&lt;/td&gt;
&lt;td&gt;per field, subtle&lt;/td&gt;
&lt;td&gt;you decide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branded types&lt;/td&gt;
&lt;td&gt;adapter layer&lt;/td&gt;
&lt;td&gt;adapter layer&lt;/td&gt;
&lt;td&gt;adapter layer&lt;/td&gt;
&lt;td&gt;native scalars&lt;/td&gt;
&lt;td&gt;native zod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime validation&lt;/td&gt;
&lt;td&gt;protovalidate or zod&lt;/td&gt;
&lt;td&gt;spec then zod&lt;/td&gt;
&lt;td&gt;struct tags, then zod&lt;/td&gt;
&lt;td&gt;add zod at edge&lt;/td&gt;
&lt;td&gt;built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typed errors&lt;/td&gt;
&lt;td&gt;status codes&lt;/td&gt;
&lt;td&gt;convention&lt;/td&gt;
&lt;td&gt;RFC 7807 native&lt;/td&gt;
&lt;td&gt;error extensions&lt;/td&gt;
&lt;td&gt;by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Toolchain weight&lt;/td&gt;
&lt;td&gt;buf, codegen&lt;/td&gt;
&lt;td&gt;codegen&lt;/td&gt;
&lt;td&gt;one library&lt;/td&gt;
&lt;td&gt;two codegens, runtime&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance at scale&lt;/td&gt;
&lt;td&gt;proto evolves under buf breaking checks&lt;/td&gt;
&lt;td&gt;the document can grow into a burden on large or public APIs&lt;/td&gt;
&lt;td&gt;low, falls out of Go types&lt;/td&gt;
&lt;td&gt;schema plus codegen config plus resolvers&lt;/td&gt;
&lt;td&gt;discipline bound, drifts silently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;subscriptions&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A note on payload size
&lt;/h2&gt;

&lt;p&gt;People ask about performance, so here is the one number that is honest to give.&lt;br&gt;
The companion repo serializes the same task into each wire format and measures the bytes, raw and after gzip. This is serialization only, not latency or throughput, and in a real service the database and network dominate, not a few hundred bytes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;single task (raw / gzip)&lt;/th&gt;
&lt;th&gt;50 tasks (raw / gzip)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Protobuf (binary)&lt;/td&gt;
&lt;td&gt;128 / 134 B&lt;/td&gt;
&lt;td&gt;6550 / 193 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connect (proto JSON)&lt;/td&gt;
&lt;td&gt;232 / 208 B&lt;/td&gt;
&lt;td&gt;11710 / 286 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAPI / REST JSON&lt;/td&gt;
&lt;td&gt;215 / 197 B&lt;/td&gt;
&lt;td&gt;10829 / 281 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GraphQL JSON&lt;/td&gt;
&lt;td&gt;233 / 209 B&lt;/td&gt;
&lt;td&gt;10848 / 296 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Protobuf binary is about half the raw size, and the gap widens with list length.&lt;br&gt;
After gzip it shrinks: for a single small object the formats land within tens of bytes of each other, and the binary can even be larger than its raw form because gzip has fixed overhead on tiny inputs. At list scale the binary stays around a third smaller gzipped, which matters if you ship large responses and is a rounding error if you do not. The three JSON formats are within a few percent of each other, because they are all just JSON with different envelopes. None of this is a reason to pick an approach. The source of truth is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;There is no single winner, but the choice is not a coin flip either.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reach for &lt;strong&gt;ConnectRPC&lt;/strong&gt; for greenfield typed RPC between a Go service and a TS client. One contract, generated both sides, typed errors, streaming. The adapter to branded domain types is small and worth it.&lt;/li&gt;
&lt;li&gt;Reach for &lt;strong&gt;OpenAPI spec first&lt;/strong&gt; when the API is REST shaped or public, so external consumers can generate their own clients from a document you own.&lt;/li&gt;
&lt;li&gt;Reach for &lt;strong&gt;GraphQL&lt;/strong&gt; when the data is graph shaped and many clients want different slices. The custom scalar support for branded types is the best of the five, but you pay for it with the rest of GraphQL.&lt;/li&gt;
&lt;li&gt;Reach for &lt;strong&gt;OpenAPI code first with Huma or Fuego&lt;/strong&gt; when the Go service is the sole producer and you want the spec to fall out of real Go types rather than be maintained by hand. This is a genuinely strong default now, not the weak option it was in the comment driven era. Skip the comment based tools.&lt;/li&gt;
&lt;li&gt;Reach for &lt;strong&gt;manual plus zod&lt;/strong&gt; for a spike or a tiny surface, and plan to migrate before discipline becomes the only thing holding the contract together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing none of them do for free is hand you domain types. They all hand you transport types. Branded ids, exhaustive enums, and the null versus absent distinction live in an adapter you write once and keep thin. Decide where your source of truth lives first. The rest is the cost of getting from there back to a type you can actually trust.&lt;/p&gt;

&lt;p&gt;The runnable code for all five is in the &lt;a href="https://github.com/Aejkatappaja/go-ts-types" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;, one directory each, sharing one domain.&lt;/p&gt;

</description>
      <category>go</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>I built phantom-ui - a Web Component that generates skeleton loaders from your real DOM</title>
      <dc:creator>Aejkatappaja</dc:creator>
      <pubDate>Tue, 14 Apr 2026 12:01:19 +0000</pubDate>
      <link>https://dev.to/aejkatappaja/i-built-a-web-component-that-generates-skeleton-loaders-from-your-real-dom-59ae</link>
      <guid>https://dev.to/aejkatappaja/i-built-a-web-component-that-generates-skeleton-loaders-from-your-real-dom-59ae</guid>
      <description>&lt;p&gt;&lt;em&gt;Updated for v1.4.0: added a refresh / stale-while-revalidate &lt;code&gt;overlay&lt;/code&gt; mode (jump).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Skeleton loaders are one of those things that sound simple until you actually build them. You end up hand-coding a second layout with gray blocks that &lt;em&gt;kind of&lt;/em&gt; matches the real one. Then the design changes, nobody updates the skeleton, and it drifts out of sync. Classic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;The DOM already knows what your UI looks like. Positions, sizes, border-radius, everything. So why are we manually recreating that as a skeleton?&lt;/p&gt;

&lt;p&gt;phantom-ui wraps your content with &lt;code&gt;&amp;lt;phantom-ui loading&amp;gt;&lt;/code&gt;, walks the DOM tree, measures every leaf element with &lt;code&gt;getBoundingClientRect&lt;/code&gt;, and overlays animated shimmer blocks at the exact same coordinates. When loading ends, it fades out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;phantom-ui&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;{user?.avatar}&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"48"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"48"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;{user?.name ?? "Placeholder Name"}&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;{user?.bio ?? "A short bio goes here."}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/phantom-ui&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No separate skeleton template. No config file. Your real component &lt;em&gt;is&lt;/em&gt; the skeleton.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works under the hood
&lt;/h2&gt;

&lt;p&gt;When the &lt;code&gt;loading&lt;/code&gt; attribute is set, phantom-ui:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hides the slotted text with &lt;code&gt;-webkit-text-fill-color: transparent&lt;/code&gt; and images with &lt;code&gt;opacity: 0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Walks the slotted DOM tree recursively&lt;/li&gt;
&lt;li&gt;Skips containers and captures leaf elements. Certain tags (&lt;code&gt;IMG&lt;/code&gt;, &lt;code&gt;SVG&lt;/code&gt;, &lt;code&gt;VIDEO&lt;/code&gt;, &lt;code&gt;CANVAS&lt;/code&gt;, &lt;code&gt;IFRAME&lt;/code&gt;, &lt;code&gt;INPUT&lt;/code&gt;, &lt;code&gt;TEXTAREA&lt;/code&gt;, &lt;code&gt;BUTTON&lt;/code&gt;) are always treated as leaves regardless of children&lt;/li&gt;
&lt;li&gt;Calls &lt;code&gt;getBoundingClientRect&lt;/code&gt; on each to get position and size relative to the host&lt;/li&gt;
&lt;li&gt;Reads &lt;code&gt;border-radius&lt;/code&gt; from computed styles&lt;/li&gt;
&lt;li&gt;For container elements with a visible background, border, or box-shadow, it captures those styles separately (using &lt;code&gt;getComputedStyle&lt;/code&gt; with individual &lt;code&gt;borderWidth&lt;/code&gt;/&lt;code&gt;borderStyle&lt;/code&gt;/&lt;code&gt;borderColor&lt;/code&gt; since the &lt;code&gt;border&lt;/code&gt; shorthand returns empty in computed styles)&lt;/li&gt;
&lt;li&gt;Renders absolutely-positioned shimmer blocks inside the Shadow DOM overlay&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The shimmer animation is pure CSS, a &lt;code&gt;linear-gradient&lt;/code&gt; sweep using &lt;code&gt;background-position&lt;/code&gt; animation. No JavaScript animation loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.shimmer-block&lt;/span&gt;&lt;span class="nd"&gt;::after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;90deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;30%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;70%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;background-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;200%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shimmer-sweep&lt;/span&gt; &lt;span class="m"&gt;1.5s&lt;/span&gt; &lt;span class="n"&gt;linear&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;ResizeObserver&lt;/code&gt; watches for layout changes. A &lt;code&gt;MutationObserver&lt;/code&gt; catches DOM mutations (it disconnects during measurement to avoid re-triggering itself when injecting measurement spans for table cells). Both re-trigger &lt;code&gt;_measure()&lt;/code&gt; automatically so the skeleton stays in sync. Observers are only active while &lt;code&gt;loading&lt;/code&gt; is true and are properly disconnected on cleanup.&lt;/p&gt;

&lt;p&gt;Measurement is batched via &lt;code&gt;requestAnimationFrame&lt;/code&gt;, so multiple DOM mutations in the same frame result in a single measurement pass instead of thrashing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Table cell handling
&lt;/h3&gt;

&lt;p&gt;Table cells (&lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;th&amp;gt;&lt;/code&gt;) get special treatment. A &lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt; is often wider than its text content because of table layout. phantom-ui temporarily injects a &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; inside the cell, measures the span's width instead of the cell's, then removes it. This produces shimmer blocks that match the text width, not the full column width.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dual Shadow DOM / Light DOM styles
&lt;/h3&gt;

&lt;p&gt;Content hiding requires two separate style strategies. The shimmer overlay lives in Shadow DOM (scoped, encapsulated). But the slotted content lives in Light DOM, and Shadow DOM styles can't fully target deep descendants of slotted elements. So phantom-ui injects a small stylesheet into the document head that targets &lt;code&gt;phantom-ui[loading] *&lt;/code&gt; to hide text and images. This is done once, deduplicated by checking for an existing style element ID.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;loading&lt;/code&gt; attribute uses a custom Lit converter that treats the string &lt;code&gt;"false"&lt;/code&gt; as falsy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;converter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;fromAttribute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;toAttribute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means &lt;code&gt;&amp;lt;phantom-ui loading="false"&amp;gt;&lt;/code&gt; removes the skeleton, which avoids a common gotcha with frameworks that serialize booleans as strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But you need the data to render the DOM?"
&lt;/h2&gt;

&lt;p&gt;This is the first thing people ask. If the content is behind &lt;code&gt;{#if data}&lt;/code&gt; or &lt;code&gt;data &amp;amp;&amp;amp; &amp;lt;Component /&amp;gt;&lt;/code&gt;, there's nothing in the DOM to measure. Fair point.&lt;/p&gt;

&lt;p&gt;The trick is to always render the structure with fallback values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;phantom&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="na"&gt;ui&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"card"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;avatar&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"48"&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"48"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Placeholder Name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;bio&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;A short bio goes here.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;phantom&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="na"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback text gives elements a size. It's invisible during loading anyway. Yeah, it's a tradeoff, you structure your template a bit differently. But you never maintain a separate skeleton.&lt;/p&gt;

&lt;p&gt;For elements that don't have a natural size yet (image without &lt;code&gt;width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt;, a div filled by JS), you can force dimensions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;data-shimmer-width=&lt;/span&gt;&lt;span class="s"&gt;"200"&lt;/span&gt; &lt;span class="na"&gt;data-shimmer-height=&lt;/span&gt;&lt;span class="s"&gt;"150"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;phantom-ui will use those values instead of the measured ones. This also works for elements with zero size, they won't be skipped if an override is present.&lt;/p&gt;

&lt;p&gt;For lists where you don't know the count, the &lt;code&gt;count&lt;/code&gt; attribute repeats skeleton rows from a single template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;phantom-ui&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt; &lt;span class="na"&gt;count=&lt;/span&gt;&lt;span class="s"&gt;"5"&lt;/span&gt; &lt;span class="na"&gt;count-gap=&lt;/span&gt;&lt;span class="s"&gt;"12"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"user-row"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"32"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"32"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;Placeholder&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/phantom-ui&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When using &lt;code&gt;count&lt;/code&gt;, if the template element has a visible background, border, or box-shadow, phantom-ui replicates those on each repeated row as &lt;code&gt;.shimmer-container-block&lt;/code&gt; elements. This means your skeleton rows look like real cards, not just floating shimmer blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Web Component?
&lt;/h2&gt;

&lt;p&gt;I didn't want to build a React version, then a Vue version, then a Svelte one. One component that works everywhere, that's it.&lt;/p&gt;

&lt;p&gt;phantom-ui is built with &lt;a href="https://lit.dev" rel="noopener noreferrer"&gt;Lit&lt;/a&gt; (~8kb total) and registers as &lt;code&gt;&amp;lt;phantom-ui&amp;gt;&lt;/code&gt;. Works out of the box with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;React 19+&lt;/strong&gt; sets properties natively on custom elements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vue&lt;/strong&gt; detects properties via the &lt;code&gt;in&lt;/code&gt; operator&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Svelte&lt;/strong&gt; handles boolean attributes natively&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Angular&lt;/strong&gt; with &lt;code&gt;CUSTOM_ELEMENTS_SCHEMA&lt;/code&gt; and &lt;code&gt;[attr.loading]="loading() ? '' : null"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solid&lt;/strong&gt; with &lt;code&gt;attr:loading={loading() ? "" : null}&lt;/code&gt; (the &lt;code&gt;attr:&lt;/code&gt; prefix forces attribute mode, and &lt;code&gt;null&lt;/code&gt; removes it, important because &lt;code&gt;attr:loading={false}&lt;/code&gt; would set &lt;code&gt;loading="false"&lt;/code&gt; in the DOM, which the CSS selector &lt;code&gt;phantom-ui[loading]&lt;/code&gt; still matches)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qwik&lt;/strong&gt; with a dynamic &lt;code&gt;import()&lt;/code&gt; in &lt;code&gt;useVisibleTask$&lt;/code&gt; and a &lt;code&gt;ready&lt;/code&gt; signal guard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTMX&lt;/strong&gt; with a CDN script tag and &lt;code&gt;hx-on::after-swap&lt;/code&gt; to remove the loading attribute&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vanilla HTML&lt;/strong&gt; just a script tag&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Zero-config TypeScript setup
&lt;/h2&gt;

&lt;p&gt;There's no postinstall hook, nothing runs when you install the package. Setup is an optional one-time command you run when you want it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @aejkatappaja/phantom-ui init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It detects your framework and: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generates a &lt;code&gt;phantom-ui.d.ts&lt;/code&gt; with JSX IntrinsicElements declarations tailored to your framework (React, Solid, Qwik each need different type augmentations)&lt;/li&gt;
&lt;li&gt;For SSR frameworks (Next.js, Nuxt, SvelteKit, Remix, Qwik), adds the pre-hydration CSS import to your root layout &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both steps are tiny and documented for manual setup too. Your source is never touched unless you run the command.&lt;/p&gt;

&lt;p&gt;The TypeScript types include full JSDoc on every property, so you get autocomplete and hover docs in your editor. The package also ships a Custom Elements Manifest (&lt;code&gt;custom-elements.json&lt;/code&gt;) for IDE tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Animation modes and shimmer direction
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;4 animation modes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;shimmer&lt;/code&gt; (default), a gradient sweep moving across each block&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pulse&lt;/code&gt;, opacity oscillation between full and faded&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;breathe&lt;/code&gt;, subtle scale and opacity breathing effect&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;solid&lt;/code&gt;, static blocks with no animation
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;phantom-ui&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt; &lt;span class="na"&gt;animation=&lt;/span&gt;&lt;span class="s"&gt;"pulse"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/phantom-ui&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Shimmer direction&lt;/strong&gt; controls the sweep direction of the gradient. Only applies to &lt;code&gt;animation="shimmer"&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ltr&lt;/code&gt;, left to right (default)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rtl&lt;/code&gt;, right to left, useful for RTL layouts (Arabic, Hebrew)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ttb&lt;/code&gt;, top to bottom&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;btt&lt;/code&gt;, bottom to top
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;phantom-ui&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt; &lt;span class="na"&gt;shimmer-direction=&lt;/span&gt;&lt;span class="s"&gt;"rtl"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/phantom-ui&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, each direction changes the gradient angle (&lt;code&gt;90deg&lt;/code&gt; vs &lt;code&gt;180deg&lt;/code&gt;) and the &lt;code&gt;background-size&lt;/code&gt;/&lt;code&gt;background-position&lt;/code&gt; axis. The CSS uses &lt;code&gt;:host([shimmer-direction="rtl"])&lt;/code&gt; selectors to swap the keyframes, no JS involved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stagger:&lt;/strong&gt; delay in seconds between each block's animation start. Creates a wave effect where blocks animate one after another.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;phantom-ui&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt; &lt;span class="na"&gt;stagger=&lt;/span&gt;&lt;span class="s"&gt;"0.05"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/phantom-ui&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Reveal:&lt;/strong&gt; smooth fade-out transition in seconds when loading ends. The overlay gets an &lt;code&gt;opacity: 0&lt;/code&gt; transition via a &lt;code&gt;.revealing&lt;/code&gt; class, then removes itself from the DOM after the transition completes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;phantom-ui&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt; &lt;span class="na"&gt;reveal=&lt;/span&gt;&lt;span class="s"&gt;"0.3"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/phantom-ui&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Refresh mode (overlay)
&lt;/h2&gt;

&lt;p&gt;Skeletons are for the first load, when there's no data yet. But a lot of loading is really a refetch: you already have a result on screen and you're refreshing it. Hiding it behind a skeleton feels like a step backwards.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mode="overlay"&lt;/code&gt; handles that. Your existing content stays on screen, dimmed, while the same structure-aware light glint sweeps over it. It's not clickable during the refresh, so users can't act on stale data, but it stays readable and announced with aria-busy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// skeleton on first load, overlay on refetch&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;phantom&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;ui&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;isFetching&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;skeleton&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;overlay&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;renderRow&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/phantom-ui&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tune the dim with the &lt;code&gt;--phantom-content-opacity&lt;/code&gt; CSS variable (default 0.5). It reuses the same measurement engine as the skeleton, so the glint follows your real layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fine-grained control
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;data-shimmer-ignore&lt;/code&gt;&lt;/strong&gt; keeps an element and its children visible during loading. Useful for static labels, navigation, or interactive elements that should remain usable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;nav&lt;/span&gt; &lt;span class="na"&gt;data-shimmer-ignore&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Home&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/nav&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The light DOM CSS resets &lt;code&gt;-webkit-text-fill-color&lt;/code&gt;, &lt;code&gt;pointer-events&lt;/code&gt;, and &lt;code&gt;opacity&lt;/code&gt; for ignored elements and their descendants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;data-shimmer-no-children&lt;/code&gt;&lt;/strong&gt; captures a container as a single shimmer block instead of walking its children. Useful for charts, progress bars, or any element where child structure doesn't map to meaningful skeleton blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"chart"&lt;/span&gt; &lt;span class="na"&gt;data-shimmer-no-children&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;canvas&amp;gt;&amp;lt;/canvas&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;data-shimmer-width&lt;/code&gt; / &lt;code&gt;data-shimmer-height&lt;/code&gt;&lt;/strong&gt; override measured dimensions. If only one axis is set and the other is zero, the element is still captured (normally zero-size elements are skipped):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;data-shimmer-width=&lt;/span&gt;&lt;span class="s"&gt;"120"&lt;/span&gt; &lt;span class="na"&gt;data-shimmer-height=&lt;/span&gt;&lt;span class="s"&gt;"24"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Accessibility
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;phantom-ui&lt;/code&gt; automatically sets &lt;code&gt;aria-busy="true"&lt;/code&gt; on the host element when loading, and &lt;code&gt;aria-busy="false"&lt;/code&gt; when loading ends. Screen readers use this to announce that content is being loaded. The shimmer overlay also has &lt;code&gt;aria-hidden="true"&lt;/code&gt; so assistive technology ignores the decorative blocks entirely.&lt;/p&gt;

&lt;p&gt;While loading, the placeholder content underneath is also made &lt;code&gt;inert&lt;/code&gt;, so it stays out of the tab order and the accessibility tree. Keyboard and screen reader users never land on the invisible elements while the skeleton is showing. Anything you mark with &lt;code&gt;data-shimmer-ignore&lt;/code&gt; stays interactive.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSR support
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;phantom-ui&lt;/code&gt; uses browser APIs (&lt;code&gt;getBoundingClientRect&lt;/code&gt;, &lt;code&gt;ResizeObserver&lt;/code&gt;, &lt;code&gt;customElements&lt;/code&gt;), so the import must happen client-side. But the &lt;code&gt;&amp;lt;phantom-ui&amp;gt;&lt;/code&gt; HTML tag is safe in server-rendered markup. The browser treats it as an unknown element until hydration.&lt;/p&gt;

&lt;p&gt;The problem: before JavaScript loads, content inside &lt;code&gt;&amp;lt;phantom-ui loading&amp;gt;&lt;/code&gt; can briefly flash as visible text. The package ships &lt;code&gt;ssr.css&lt;/code&gt; that hides this content immediately with no JS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"@aejkatappaja/phantom-ui/ssr.css"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The optional &lt;code&gt;init&lt;/code&gt; command adds this import to your layout file, or you add the one line yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;Benchmarked on a Mac Studio M4 Max:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Elements&lt;/th&gt;
&lt;th&gt;Measurement time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;&amp;lt; 3ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;&amp;lt; 15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;&amp;lt; 31ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The measurement runs once per &lt;code&gt;loading&lt;/code&gt; toggle (plus re-runs on resize/mutation). It's a single synchronous DOM walk, no layout thrashing, since &lt;code&gt;getBoundingClientRect&lt;/code&gt; reads are batched before any writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @aejkatappaja/phantom-ui
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or via CDN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://unpkg.com/@aejkatappaja/phantom-ui/dist/phantom-ui.cdn.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Links:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Aejkatappaja/phantom-ui" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aejkatappaja.github.io/phantom-ui/demo" rel="noopener noreferrer"&gt;Interactive demo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aejkatappaja.github.io/phantom-ui/" rel="noopener noreferrer"&gt;Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://stackblitz.com/github/Aejkatappaja/phantom-ui/tree/main/examples/react-query-demo" rel="noopener noreferrer"&gt;StackBlitz playground (React + TanStack Query)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback welcome, especially on edge cases you've hit with skeleton loaders.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>webcomponents</category>
      <category>typescript</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
