<?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: nyaomaru</title>
    <description>The latest articles on DEV Community by nyaomaru (@nyaomaru).</description>
    <link>https://dev.to/nyaomaru</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1436158%2Fa78bc4ed-7796-4760-baa5-06283c2404d6.png</url>
      <title>DEV Community: nyaomaru</title>
      <link>https://dev.to/nyaomaru</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nyaomaru"/>
    <language>en</language>
    <item>
      <title>Which OpenAPI Codegen Should You Choose? openapi-typescript vs hey-api vs Orval vs Kubb</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Wed, 20 May 2026 13:34:57 +0000</pubDate>
      <link>https://dev.to/nyaomaru/which-openapi-codegen-should-you-choose-openapi-typescript-vs-hey-api-vs-orval-vs-kubb-100p</link>
      <guid>https://dev.to/nyaomaru/which-openapi-codegen-should-you-choose-openapi-typescript-vs-hey-api-vs-orval-vs-kubb-100p</guid>
      <description>&lt;p&gt;Hoi hoi!&lt;/p&gt;

&lt;p&gt;I'm &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;@nyaomaru&lt;/a&gt;, a frontend engineer who thought OpenAPI codegen would make API clients easier, but somehow ended up lost in a forest of generated files. 🌳🌳🌳&lt;/p&gt;

&lt;p&gt;Writing API clients by hand is painful.&lt;/p&gt;

&lt;p&gt;Usually, we want things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;generated TypeScript types&lt;/li&gt;
&lt;li&gt;generated request functions&lt;/li&gt;
&lt;li&gt;maybe &lt;code&gt;TanStack Query&lt;/code&gt; hooks&lt;/li&gt;
&lt;li&gt;maybe &lt;code&gt;MSW&lt;/code&gt; mocks&lt;/li&gt;
&lt;li&gt;maybe even &lt;code&gt;Zod&lt;/code&gt; schemas&lt;/li&gt;
&lt;li&gt;and, if possible, everything growing automatically from OpenAPI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is where OpenAPI codegen tools are useful.&lt;/p&gt;

&lt;p&gt;But after comparing several tools with a fairly large real-world schema, I realized something important:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAPI codegen is not only about what a tool can generate.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The generated output itself also matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how many files it creates&lt;/li&gt;
&lt;li&gt;how fast generation is&lt;/li&gt;
&lt;li&gt;how friendly it is to linting&lt;/li&gt;
&lt;li&gt;how easy it is for the IDE to index&lt;/li&gt;
&lt;li&gt;how errors are modeled&lt;/li&gt;
&lt;li&gt;how authentication is handled&lt;/li&gt;
&lt;li&gt;how much generated code your team is willing to maintain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this article, I compared the following four tools:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Version tested&lt;/th&gt;
&lt;th&gt;Main setup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;v7.13.0&lt;/td&gt;
&lt;td&gt;types only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@hey-api/openapi-ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;v0.96.1&lt;/td&gt;
&lt;td&gt;types + SDK + fetch client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;v8.8.1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mode: "tags-split"&lt;/code&gt; / &lt;code&gt;client: "fetch"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;v4.37.2&lt;/td&gt;
&lt;td&gt;client / types / schemas generated by plugins&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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.amazonaws.com%2Fuploads%2Farticles%2Frr61d7ozn3dwxht4s5jo.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.amazonaws.com%2Fuploads%2Farticles%2Frr61d7ozn3dwxht4s5jo.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The OpenAPI schema used for this comparison was roughly this size. (It was generated by &lt;code&gt;drf-spectacular&lt;/code&gt;)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;openapi.yaml&lt;/code&gt; lines&lt;/td&gt;
&lt;td&gt;about 75,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;openapi.yaml&lt;/code&gt; size&lt;/td&gt;
&lt;td&gt;about 2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;operations&lt;/td&gt;
&lt;td&gt;about 1,200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: Some endpoint names, schema names, type names, and directory names in this article are simplified or replaced for publication. Also, these libraries move fast, so the generated output and issues described here are based on the versions tested at the time of writing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a small API, honestly, any of these tools may work fine.&lt;/p&gt;

&lt;p&gt;But at this scale, differences become very visible in generation speed, file count, lint behavior, enum handling, error models, and day-to-day maintainability.&lt;/p&gt;

&lt;p&gt;Let's walk through the comparison.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 TL;DR
&lt;/h2&gt;

&lt;p&gt;My conclusion is this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Tool I would choose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;You only need TypeScript types&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You want operationId-based SDK functions and interceptors&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@hey-api/openapi-ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You want &lt;code&gt;TanStack Query&lt;/code&gt;, &lt;code&gt;Zod&lt;/code&gt;, &lt;code&gt;MSW&lt;/code&gt;, or &lt;code&gt;Faker&lt;/code&gt; generated from OpenAPI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You value 1 operation = 1 file and plugin-based codegen architecture&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For my use case, &lt;strong&gt;hey-api felt the most practical&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reasons were simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it generates functions from &lt;code&gt;operationId&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;arguments are consistently shaped like &lt;code&gt;{ query, path, body }&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;it has a result-style error model&lt;/li&gt;
&lt;li&gt;it supports axios-like interceptors&lt;/li&gt;
&lt;li&gt;the generated output is much lighter than Orval or Kubb&lt;/li&gt;
&lt;li&gt;it still leaves room for &lt;code&gt;TanStack Query&lt;/code&gt; and &lt;code&gt;Zod&lt;/code&gt; plugins later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That said, if your project already uses &lt;code&gt;openapi-typescript + openapi-fetch&lt;/code&gt; and you are happy with it, I do not think you need to migrate immediately.&lt;/p&gt;

&lt;p&gt;The difference is not huge enough to justify migration by itself.&lt;/p&gt;

&lt;p&gt;I would consider switching only when you start feeling pain around SDK functions, interceptors, result handling, mocks, generated hooks, or schema-driven tooling.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In this article, I will call &lt;code&gt;@hey-api/openapi-ts&lt;/code&gt; simply &lt;code&gt;hey-api&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🌓 Two families of OpenAPI codegen tools
&lt;/h2&gt;

&lt;p&gt;Before comparing each tool one by one, I think it is easier to divide them into two families.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;Family A: thin output, client-focused&lt;/th&gt;
&lt;th&gt;Family B: SDK-first, ecosystem generation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Representative tools&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;openapi-typescript&lt;/code&gt; / &lt;code&gt;hey-api&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Orval&lt;/code&gt; / &lt;code&gt;Kubb&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main purpose&lt;/td&gt;
&lt;td&gt;accurate types and lightweight runtime&lt;/td&gt;
&lt;td&gt;generated SDK + hooks + mocks + validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error model&lt;/td&gt;
&lt;td&gt;result-style &lt;code&gt;{ data, error, response }&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;wrapper / throw / client-dependent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output size&lt;/td&gt;
&lt;td&gt;small: types only or compact SDK&lt;/td&gt;
&lt;td&gt;large: tag-level or operation-level files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ecosystem features&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TanStack Query&lt;/code&gt; / &lt;code&gt;Zod&lt;/code&gt; depending on tool&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TanStack Query&lt;/code&gt; / &lt;code&gt;Zod&lt;/code&gt; / &lt;code&gt;MSW&lt;/code&gt; / &lt;code&gt;Faker&lt;/code&gt; / &lt;code&gt;SWR&lt;/code&gt; etc.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configuration style&lt;/td&gt;
&lt;td&gt;minimal&lt;/td&gt;
&lt;td&gt;codegen platform&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This distinction is important.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;openapi-typescript&lt;/code&gt; and &lt;code&gt;Kubb&lt;/code&gt; are both OpenAPI codegen tools, but they are not trying to solve the same problem.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;openapi-typescript&lt;/code&gt; mainly focuses on generating accurate TypeScript types.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Kubb&lt;/code&gt;, on the other hand, is closer to a codegen platform that can generate clients, types, schemas, mocks, and other assets in a structured way.&lt;/p&gt;

&lt;p&gt;Comparing them directly is a bit like comparing a kitchen knife with a full kitchen. 🧑‍🍳🔪&lt;/p&gt;

&lt;p&gt;Both are related to cooking, but they are not the same kind of thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏎️ Generation speed
&lt;/h2&gt;

&lt;p&gt;First, generation speed.&lt;/p&gt;

&lt;p&gt;I measured each command three times on &lt;code&gt;WSL2&lt;/code&gt; / &lt;code&gt;Node v24.14&lt;/code&gt; using &lt;code&gt;npx &amp;lt;cmd&amp;gt;&lt;/code&gt;. So the numbers include &lt;code&gt;npx&lt;/code&gt; startup overhead. (a custom-built PC)&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;1st run&lt;/th&gt;
&lt;th&gt;2nd run&lt;/th&gt;
&lt;th&gt;3rd run&lt;/th&gt;
&lt;th&gt;Average&lt;/th&gt;
&lt;th&gt;Max RSS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.66 s&lt;/td&gt;
&lt;td&gt;1.50 s&lt;/td&gt;
&lt;td&gt;1.40 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;about 1.5 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;about 335 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@hey-api/openapi-ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8.74 s&lt;/td&gt;
&lt;td&gt;8.84 s&lt;/td&gt;
&lt;td&gt;6.43 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;about 8.0 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;about 495 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6.01 s&lt;/td&gt;
&lt;td&gt;6.12 s&lt;/td&gt;
&lt;td&gt;4.48 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;about 5.5 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;about 415 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;17.29 s&lt;/td&gt;
&lt;td&gt;19.82 s&lt;/td&gt;
&lt;td&gt;17.13 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;about 18.1 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;about 880 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Relative to &lt;code&gt;openapi-typescript&lt;/code&gt;, the difference looked like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Relative speed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;about 3.7x slower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hey-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;about 5.3x slower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;about 12x slower&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why openapi-typescript is so fast
&lt;/h3&gt;

&lt;p&gt;In this setup, &lt;code&gt;openapi-typescript&lt;/code&gt; only generates types into a single file.&lt;/p&gt;

&lt;p&gt;The workflow is basically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;read the schema&lt;/li&gt;
&lt;li&gt;convert it into TypeScript types&lt;/li&gt;
&lt;li&gt;write one file&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That means very little file I/O.&lt;/p&gt;

&lt;p&gt;The generated output was just &lt;code&gt;types.gen.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At about 1.5 seconds, it is fast enough that running it in CI, pre-commit, or during local development barely feels like a problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Kubb is slower
&lt;/h3&gt;

&lt;p&gt;Kubb has a different philosophy.&lt;/p&gt;

&lt;p&gt;It splits things much more aggressively.&lt;/p&gt;

&lt;p&gt;In this test, I generated operation-level clients, operation-level types, and schemas.&lt;/p&gt;

&lt;p&gt;So the cost is not only computation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The number of files written to disk becomes a major factor.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The structure is clean from a bundling and organization perspective, but with around 1,200 operations, the file system cost becomes noticeable.&lt;/p&gt;

&lt;p&gt;For this kind of schema, I would not treat Kubb as something I casually run on every small change unless the generation target is narrowed.&lt;/p&gt;

&lt;p&gt;It feels more suitable for a workflow where generated files are committed, generated in CI, or carefully scoped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Orval was faster than I expected
&lt;/h3&gt;

&lt;p&gt;I expected Orval to be slower.&lt;/p&gt;

&lt;p&gt;It can generate things like &lt;code&gt;TanStack Query&lt;/code&gt;, &lt;code&gt;Zod&lt;/code&gt;, and &lt;code&gt;MSW&lt;/code&gt;, so I assumed it would be heavier.&lt;/p&gt;

&lt;p&gt;But with &lt;code&gt;tags-split&lt;/code&gt;, the main functions are grouped by tag.&lt;/p&gt;

&lt;p&gt;In my schema, there were about 25 tags.&lt;/p&gt;

&lt;p&gt;So Orval was not generating one giant file, but it also was not generating one file per operation.&lt;/p&gt;

&lt;p&gt;That middle ground worked well for speed.&lt;/p&gt;

&lt;p&gt;However, this does not mean the output was small.&lt;/p&gt;

&lt;p&gt;It still generated more than 2,600 schema files.&lt;/p&gt;




&lt;h2&gt;
  
  
  🗺️ Output size
&lt;/h2&gt;

&lt;p&gt;Next, generated output size and file count.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;File count&lt;/th&gt;
&lt;th&gt;Total size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/api/types.gen.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2.4 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@hey-api/openapi-ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/api/hey-api/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;2.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/api/orval/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,719&lt;/td&gt;
&lt;td&gt;14 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/api/kubb/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3,877&lt;/td&gt;
&lt;td&gt;24 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Just from these numbers, the tools clearly live in different worlds.&lt;/p&gt;

&lt;p&gt;My rough feeling was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;openapi-typescript&lt;/code&gt;: one house 🏠&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hey-api&lt;/code&gt;: a small town 🏙️&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Orval&lt;/code&gt;: a small forest 🌲🌲&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Kubb&lt;/code&gt;: a deep forest 🌳🌳🌳&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;BTW, I love rural life so forest is better. 🌳&lt;/p&gt;

&lt;h3&gt;
  
  
  openapi-typescript
&lt;/h3&gt;

&lt;p&gt;The output was only &lt;code&gt;types.gen.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It contained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;paths&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;components&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;operations&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One file is simple and easy to understand.&lt;/p&gt;

&lt;p&gt;But a 2.4 MB TypeScript file can still be uncomfortable for IDE indexing.&lt;/p&gt;

&lt;p&gt;So even though the file count is tiny, the single file itself is large.&lt;/p&gt;

&lt;h3&gt;
  
  
  hey-api
&lt;/h3&gt;

&lt;p&gt;hey-api generated 16 files.&lt;/p&gt;

&lt;p&gt;The main files looked like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;types.gen.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;request / response types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sdk.gen.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;generated operation functions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;index.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;barrel exports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.gen.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;default client instance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fetch client implementation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;core/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;auth, serializers, utils, etc.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;types.gen.ts&lt;/code&gt; was about 1.7 MB, and &lt;code&gt;sdk.gen.ts&lt;/code&gt; was about 750 KB.&lt;/p&gt;

&lt;p&gt;With 1,200 operations, the SDK file naturally becomes large.&lt;/p&gt;

&lt;p&gt;But compared with Orval and Kubb, the output still felt manageable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Orval
&lt;/h3&gt;

&lt;p&gt;I used Orval with &lt;code&gt;tags-split&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The structure looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/api/orval/
├─ schemas/
│  ├─ ordersWriteRequest.ts
│  ├─ ordersListParams.ts
│  └─ ...
├─ order/
│  └─ order.ts
├─ product/
│  └─ product.ts
└─ ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Functions were grouped by tag.&lt;/p&gt;

&lt;p&gt;In this schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tag files: about 25&lt;/li&gt;
&lt;li&gt;schema files: more than 2,600&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The largest generated file was around 30,000 lines.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubb
&lt;/h3&gt;

&lt;p&gt;Kubb generated an even more split structure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/api/kubb/
├─ types/
├─ clients/
├─ schemas/
└─ index.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Directory&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;types/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;operation-level types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;clients/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;operation-level functions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;schemas/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;individual schemas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;index.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;barrel exports&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The nice part is that the structure is close to 1 operation = 1 file.&lt;/p&gt;

&lt;p&gt;That makes grep easy and can be friendly to tree-shaking.&lt;/p&gt;

&lt;p&gt;But the tradeoff is real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;large PR diffs&lt;/li&gt;
&lt;li&gt;heavier IDE indexing&lt;/li&gt;
&lt;li&gt;slower clone and checkout&lt;/li&gt;
&lt;li&gt;more generated files to commit or ignore&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🧪 Calling the same endpoint
&lt;/h2&gt;

&lt;p&gt;Now let's compare the actual call sites.&lt;/p&gt;

&lt;p&gt;Assume this endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;GET /orders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It accepts common list query parameters like &lt;code&gt;page&lt;/code&gt; and &lt;code&gt;page_size&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  openapi-typescript
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;openapi-typescript&lt;/code&gt; only generates types, so I used it with &lt;code&gt;openapi-fetch&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openapi-fetch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;paths&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./api/types.gen&lt;/span&gt;&lt;span class="dl"&gt;"&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;paths&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:8000&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;span class="kd"&gt;const&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="nx"&gt;response&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/orders&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;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;page&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;page_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is that the endpoint is specified as a path string:&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/orders&lt;/span&gt;&lt;span class="dl"&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 simple and readable.&lt;/p&gt;

&lt;p&gt;IDE autocomplete also works well.&lt;/p&gt;

&lt;p&gt;But it is not operationId-based, so if the endpoint changes, rename refactoring is not as direct as function-based clients.&lt;/p&gt;

&lt;h3&gt;
  
  
  hey-api
&lt;/h3&gt;

&lt;p&gt;hey-api generates SDK functions.&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ordersList&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./api/hey-api/sdk.gen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&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="nx"&gt;response&lt;/span&gt; &lt;span class="p"&gt;}&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;ordersList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;page&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;page_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The function name comes from &lt;code&gt;operationId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I like this because you can grep by function name and use IDE rename refactoring more naturally.&lt;/p&gt;

&lt;p&gt;The argument shape is also consistent:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;body&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 consistency becomes useful later when building wrappers, logging, retries, auth handling, or tracing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Orval
&lt;/h3&gt;

&lt;p&gt;For Orval, I used &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;tags-split&lt;/code&gt;, and &lt;code&gt;useNamedParameters: true&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ordersList&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./api/orval/orders/order&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&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="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;}&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;ordersList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;page&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;page_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&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 return value is like:&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="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="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;headers&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;At first glance, this is easy to use.&lt;/p&gt;

&lt;p&gt;But with the default fetch client, 4xx and 5xx responses are not thrown as errors.&lt;/p&gt;

&lt;p&gt;That point is very important in real applications.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubb
&lt;/h3&gt;

&lt;p&gt;Kubb generates operation-level functions.&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ordersList&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./api/kubb/clients/ordersList&lt;/span&gt;&lt;span class="dl"&gt;"&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;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;ordersList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;page&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;page_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&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;On success, it returns the response data directly.&lt;/p&gt;

&lt;p&gt;So the call site is very short.&lt;/p&gt;

&lt;p&gt;However, if you want to access response headers or status, the default return shape may not be enough.&lt;/p&gt;

&lt;p&gt;Also, path parameters and body arguments can become positional depending on the endpoint, which can make generic wrappers harder to design.&lt;/p&gt;

&lt;h2&gt;
  
  
  🖇️ Path params and body arguments
&lt;/h2&gt;

&lt;p&gt;A list endpoint does not show all differences, so let's compare path params and body arguments too.&lt;/p&gt;

&lt;h3&gt;
  
  
  GET by id
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// openapi-typescript&lt;/span&gt;
&lt;span class="kd"&gt;const&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/orders/{id}&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;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;123&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;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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// hey-api&lt;/span&gt;
&lt;span class="kd"&gt;const&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ordersRetrieve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;123&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;span class="p"&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;// Orval&lt;/span&gt;
&lt;span class="kd"&gt;const&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ordersRetrieve&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;123&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Kubb&lt;/span&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;ordersRetrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Summary:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Path param style&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;params.path&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hey-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ path: { id } }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ id }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;positional argument like &lt;code&gt;"123"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Kubb is short.&lt;/p&gt;

&lt;p&gt;But if an endpoint has multiple path params, positional arguments grow like &lt;code&gt;(id1, id2, ...)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is partly a preference issue, but for large API clients I usually prefer named object arguments.&lt;/p&gt;

&lt;h3&gt;
  
  
  POST body
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// openapi-typescript&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/orders&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;span class="nx"&gt;body&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// hey-api&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ordersCreate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;body&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Orval&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ordersCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&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;// Kubb&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ordersCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Orval and Kubb feel natural when the body is the first argument.&lt;/p&gt;

&lt;p&gt;But for POST endpoints without a body, this can sometimes lead to explicit &lt;code&gt;undefined&lt;/code&gt; arguments.&lt;/p&gt;

&lt;p&gt;hey-api and openapi-typescript add one level with &lt;code&gt;{ body }&lt;/code&gt;, but the shape remains consistent with &lt;code&gt;query&lt;/code&gt; and &lt;code&gt;path&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For a large application, I personally prefer the consistency of hey-api.&lt;/p&gt;




&lt;h2&gt;
  
  
  💭 Function signature philosophy
&lt;/h2&gt;

&lt;p&gt;Here is the broader comparison of call signatures.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;hey-api&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;endpoint selection&lt;/td&gt;
&lt;td&gt;URL string&lt;/td&gt;
&lt;td&gt;operationId function&lt;/td&gt;
&lt;td&gt;operationId function&lt;/td&gt;
&lt;td&gt;operationId function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;query&lt;/td&gt;
&lt;td&gt;&lt;code&gt;params.query&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ query }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;first argument&lt;/td&gt;
&lt;td&gt;first argument&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;path&lt;/td&gt;
&lt;td&gt;&lt;code&gt;params.path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ path }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{ id }&lt;/code&gt; or positional&lt;/td&gt;
&lt;td&gt;positional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;body&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ body }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ body }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;first argument&lt;/td&gt;
&lt;td&gt;first argument&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;headers&lt;/td&gt;
&lt;td&gt;options / middleware&lt;/td&gt;
&lt;td&gt;options / interceptor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RequestInit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cross-cutting behavior&lt;/td&gt;
&lt;td&gt;middleware&lt;/td&gt;
&lt;td&gt;interceptor&lt;/td&gt;
&lt;td&gt;mutator&lt;/td&gt;
&lt;td&gt;config / custom client&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key strength of hey-api is that every endpoint moves toward the same shape:&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;await&lt;/span&gt; &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;headers&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 makes cross-cutting behavior easier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;authentication&lt;/li&gt;
&lt;li&gt;logging&lt;/li&gt;
&lt;li&gt;retries&lt;/li&gt;
&lt;li&gt;trace ids&lt;/li&gt;
&lt;li&gt;request ids&lt;/li&gt;
&lt;li&gt;custom headers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Orval and Kubb often produce shorter call sites, but signatures can vary more by endpoint.&lt;/p&gt;

&lt;p&gt;In a small codebase, that is probably fine.&lt;/p&gt;

&lt;p&gt;In a 1,200-operation API, consistency matters a lot more.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚠️ Error model
&lt;/h2&gt;

&lt;p&gt;This was one of the most important differences.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Default behavior&lt;/th&gt;
&lt;th&gt;Can be changed?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;openapi-typescript&lt;/code&gt; + &lt;code&gt;openapi-fetch&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{ data, error, response }&lt;/code&gt; result union&lt;/td&gt;
&lt;td&gt;can throw via middleware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hey-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;{ data, error, response }&lt;/code&gt; result union&lt;/td&gt;
&lt;td&gt;can throw with &lt;code&gt;throwOnError: true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;wrapper like &lt;code&gt;{ data, status, headers }&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;can throw with custom fetch / mutator / config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;success returns &lt;code&gt;data&lt;/code&gt;, failure throws&lt;/td&gt;
&lt;td&gt;customizable via client&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Result union in openapi-typescript and hey-api
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;openapi-fetch&lt;/code&gt; and hey-api, non-2xx responses can be represented in &lt;code&gt;error&lt;/code&gt; if the OpenAPI schema defines them.&lt;/p&gt;

&lt;p&gt;But in my &lt;code&gt;drf-spectacular&lt;/code&gt; schema, most non-2xx responses were not described.&lt;/p&gt;

&lt;p&gt;So in practice, &lt;code&gt;error&lt;/code&gt; was often close to &lt;code&gt;never&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means I still need a runtime check like this:&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="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="nx"&gt;response&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/orders&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;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;page&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="p"&gt;},&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="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With hey-api, I can also opt into throwing behavior per call:&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="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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ordersList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;page&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="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;throwOnError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;I like having both options.&lt;/p&gt;

&lt;p&gt;Sometimes I want a result-style response.&lt;/p&gt;

&lt;p&gt;Sometimes I want HTTP errors to throw immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Orval's default fetch behavior needs attention
&lt;/h3&gt;

&lt;p&gt;With Orval's default fetch client, 4xx and 5xx responses do not throw automatically.&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;res&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;ordersList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;page&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="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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;200&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the team forgets to check &lt;code&gt;status&lt;/code&gt;, it is possible to treat an error response as if it were success data.&lt;/p&gt;

&lt;p&gt;For example, the code may try to access &lt;code&gt;data.results&lt;/code&gt; and then fail at runtime because the actual response body was an error shape.&lt;/p&gt;

&lt;p&gt;This is easy to miss if you only look at TypeScript types.&lt;/p&gt;

&lt;p&gt;If I used Orval in production, I would choose one of these patterns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;always check &lt;code&gt;status&lt;/code&gt; at the call site&lt;/li&gt;
&lt;li&gt;configure the fetch client with &lt;code&gt;override.fetch.forceSuccessResponse: true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;use &lt;code&gt;override.mutator&lt;/code&gt; and write a custom fetch wrapper for auth, refresh, and errors&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A custom fetch may look like this:&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;// custom-fetch.ts&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;customFetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;url&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;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RequestInit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Orval is powerful, but this behavior should be made explicit as a team rule.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubb's throw model
&lt;/h3&gt;

&lt;p&gt;Kubb is closer to a throw-based model by default.&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;ordersList&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;page&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On success, you get the data directly.&lt;/p&gt;

&lt;p&gt;On failure, it throws.&lt;/p&gt;

&lt;p&gt;This is pleasant in UI code.&lt;/p&gt;

&lt;p&gt;But if you need response metadata, such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Retry-After&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ETag&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;rate limit headers&lt;/li&gt;
&lt;li&gt;pagination headers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then the default return value may not be enough, and you may need a custom client or wrapper.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ Authentication and interceptors
&lt;/h2&gt;

&lt;p&gt;Authentication handling also differs quite a bit.&lt;/p&gt;

&lt;h3&gt;
  
  
  openapi-fetch
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;openapi-fetch&lt;/code&gt; supports middleware.&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;onRequest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;request&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;getToken&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already use &lt;code&gt;openapi-typescript + openapi-fetch&lt;/code&gt;, this is clean and lightweight.&lt;/p&gt;

&lt;h3&gt;
  
  
  hey-api
&lt;/h3&gt;

&lt;p&gt;hey-api has axios-like interceptors.&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interceptors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;request&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;getToken&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&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 API feels familiar.&lt;/p&gt;

&lt;p&gt;For common headers, logging, trace ids, and 401 refresh flows, this style is easy to work with.&lt;/p&gt;

&lt;p&gt;Generated hey-api functions can also include &lt;code&gt;security&lt;/code&gt; metadata.&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;ordersList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ThrowOnError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&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;options&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Options&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ordersListData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ThrowOnError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ordersListResponses&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;ThrowOnError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bearer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/orders&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;span class="nx"&gt;options&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 is a little more to understand at first, but useful for authenticated APIs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Orval
&lt;/h3&gt;

&lt;p&gt;Orval is closer to raw fetch.&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;await&lt;/span&gt; &lt;span class="nf"&gt;ordersList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;page&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;getToken&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="p"&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;You can pass headers per call.&lt;/p&gt;

&lt;p&gt;But in a real app, I would usually create a custom fetch with &lt;code&gt;override.mutator&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubb
&lt;/h3&gt;

&lt;p&gt;Kubb can configure the client or accept per-call config.&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:8000&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;Headers can also be passed per call.&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;await&lt;/span&gt; &lt;span class="nf"&gt;ordersList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;page&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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="p"&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;But it does not feel like axios-style interceptors are the default mental model.&lt;/p&gt;

&lt;p&gt;For things like 401 refresh, I would expect to wire my own client behavior.&lt;/p&gt;




&lt;h2&gt;
  
  
  🪛 TanStack Query, Zod, MSW, and Faker
&lt;/h2&gt;

&lt;p&gt;This is where the second family of tools becomes attractive.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;hey-api&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Type generation&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fetch client&lt;/td&gt;
&lt;td&gt;external &lt;code&gt;openapi-fetch&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TanStack Query&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;plugin&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zod&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;plugin&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MSW&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;limited / roadmap-dependent&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Faker&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SWR / Vue Query&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;limited&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;limited / plugin-dependent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Orval has strong built-in support around this area.&lt;/p&gt;

&lt;p&gt;It can target tools like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;TanStack Query&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SWR&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Vue Query&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Zod&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MSW&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Faker&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Kubb also has a strong plugin-based approach.&lt;/p&gt;

&lt;p&gt;hey-api is more focused around SDK generation, &lt;code&gt;TanStack Query&lt;/code&gt;, and &lt;code&gt;Zod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If your goal is mainly API clients and types, &lt;code&gt;openapi-typescript&lt;/code&gt; or hey-api may be enough.&lt;/p&gt;

&lt;p&gt;If your goal is to generate a broader frontend ecosystem from OpenAPI, Orval or Kubb becomes more interesting.&lt;/p&gt;

&lt;p&gt;In my case, I did not need &lt;code&gt;MSW&lt;/code&gt; or &lt;code&gt;Faker&lt;/code&gt; yet.&lt;/p&gt;

&lt;p&gt;So Orval and Kubb felt a bit too much for the current problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  🤯 Enum issue: drf-spectacular BlankEnum
&lt;/h2&gt;

&lt;p&gt;One very real-world issue was enum generation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;drf-spectacular&lt;/code&gt; can represent nullable or blank enum values like this:&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;BlankEnum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;NullEnum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;null&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a field may use &lt;code&gt;anyOf&lt;/code&gt; like this:&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;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;anyOf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;$ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#/components/schemas/StatusEnum"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;$ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#/components/schemas/BlankEnum"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;$ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#/components/schemas/NullEnum"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intended TypeScript value is something like:&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WAITING&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RUNNING&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SUCCESS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FAILURE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;""&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Generated BlankEnum
&lt;/h3&gt;

&lt;p&gt;The generated output differed by tool.&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;// openapi-typescript&lt;/span&gt;
&lt;span class="nx"&gt;BlankEnum&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;/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;// hey-api&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;enum&lt;/span&gt; &lt;span class="nx"&gt;BlankEnum&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="o"&gt;=&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Orval&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;BlankEnum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;BlankEnum&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;BlankEnum&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;BlankEnum&lt;/span&gt; &lt;span class="o"&gt;=&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="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&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;// Kubb&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;blankEnumEnum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&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;BlankEnumEnumKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;blankEnumEnum&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;blankEnumEnum&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;BlankEnum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BlankEnumEnumKey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Kubb, the empty string disappeared, and the type effectively became &lt;code&gt;never&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens after anyOf composition
&lt;/h3&gt;

&lt;p&gt;As a result, only Kubb dropped &lt;code&gt;""&lt;/code&gt; from the final type.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Can represent &lt;code&gt;""&lt;/code&gt; in the type?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hey-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is not just a small type detail.&lt;/p&gt;

&lt;p&gt;It can break real application code if fields like &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;file_type&lt;/code&gt;, or &lt;code&gt;update_type&lt;/code&gt; actually receive an empty string from the backend.&lt;/p&gt;

&lt;p&gt;Possible workarounds are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;patch &lt;code&gt;BlankEnum&lt;/code&gt; after generation&lt;/li&gt;
&lt;li&gt;cast affected fields to &lt;code&gt;string | null&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;wait for an upstream fix&lt;/li&gt;
&lt;li&gt;avoid this schema shape if you control the backend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use &lt;code&gt;drf-spectacular&lt;/code&gt; and have many &lt;code&gt;BlankEnum&lt;/code&gt; / &lt;code&gt;NullEnum&lt;/code&gt; patterns, I recommend checking this before adopting Kubb.&lt;/p&gt;




&lt;h2&gt;
  
  
  🥗 Body type reusability
&lt;/h2&gt;

&lt;p&gt;POST body types also differed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Body type style&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;components["schemas"]["ordersWriteRequest"]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hey-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ordersWriteRequest&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ordersWriteRequest&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ordersCreateMutationRequest&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Kubb generated endpoint-scoped wrapper types.&lt;/p&gt;

&lt;p&gt;This can be good because each operation is very explicit.&lt;/p&gt;

&lt;p&gt;But if you want to reuse the same schema in app-level code, it can be a little less convenient.&lt;/p&gt;

&lt;p&gt;For example, if a form wants to use &lt;code&gt;ordersWriteRequest&lt;/code&gt; as its submit data type, openapi-typescript, hey-api, and Orval feel more natural.&lt;/p&gt;

&lt;p&gt;With Kubb, I may need to alias the operation-scoped type:&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;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ordersCreateMutationRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./api/kubb/types/ordersCreate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;OrderWriteRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ordersCreateMutationRequest&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 not wrong.&lt;/p&gt;

&lt;p&gt;It just depends on whether your app wants shared schema types or operation-scoped types.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚪 Lint behavior
&lt;/h2&gt;

&lt;p&gt;All four tools passed &lt;code&gt;tsc --noEmit&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But linting showed differences.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;oxlint default&lt;/th&gt;
&lt;th&gt;oxlint + &lt;code&gt;no-explicit-any&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;eslint recommended&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hey-api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1,549&lt;/td&gt;
&lt;td&gt;1,585&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  openapi-typescript / hey-api / Orval lint errors
&lt;/h3&gt;

&lt;p&gt;For the first three tools, the eslint errors were mainly &lt;code&gt;no-irregular-whitespace&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In my schema, some Django docstrings contained full-width spaces.&lt;/p&gt;

&lt;p&gt;Those comments were copied into generated code, and eslint complained.&lt;/p&gt;

&lt;p&gt;This is more about source docstrings and lint configuration than generated code quality.&lt;/p&gt;

&lt;p&gt;It can usually be handled by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;skipping comments&lt;/li&gt;
&lt;li&gt;excluding generated files from lint&lt;/li&gt;
&lt;li&gt;adding lint overrides for generated code&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Kubb lint errors
&lt;/h3&gt;

&lt;p&gt;Kubb produced many &lt;code&gt;no-explicit-any&lt;/code&gt; errors.&lt;/p&gt;

&lt;p&gt;Most were in the runtime client side.&lt;/p&gt;

&lt;p&gt;This does not mean Kubb is unusable.&lt;/p&gt;

&lt;p&gt;But if generated files are included in lint, Kubb almost certainly needs ignore rules or overrides.&lt;/p&gt;

&lt;p&gt;Before adopting it, I would decide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;will generated files be committed?&lt;/li&gt;
&lt;li&gt;will generated files be linted?&lt;/li&gt;
&lt;li&gt;will generated files be checked in CI?&lt;/li&gt;
&lt;li&gt;which rules are disabled for generated code?&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📝 Configuration notes
&lt;/h2&gt;

&lt;p&gt;Here are some issues I hit while setting up each tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  openapi-typescript
&lt;/h3&gt;

&lt;p&gt;This was the easiest setup.&lt;/p&gt;

&lt;p&gt;A single CLI command worked well.&lt;/p&gt;

&lt;p&gt;I did not hit any major configuration problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  hey-api
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;hey-api&lt;/code&gt; uses &lt;code&gt;openapi-ts.config.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I hit a few small points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;output.format: "prettier"&lt;/code&gt; aborts if Prettier is not installed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;runtimeConfigPath&lt;/code&gt; is emitted as an import path relative to &lt;code&gt;output.path&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I initially assumed &lt;code&gt;runtimeConfigPath&lt;/code&gt; was relative to the project root.&lt;/p&gt;

&lt;p&gt;That was easy to misunderstand.&lt;/p&gt;

&lt;p&gt;I shared this with the author, and the related PR was merged the next day.&lt;/p&gt;

&lt;p&gt;That fast response was a positive signal for adoption.&lt;/p&gt;

&lt;h3&gt;
  
  
  Orval
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Orval&lt;/code&gt; uses &lt;code&gt;orval.config.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It can split outputs by target, which is useful if you want to generate SDKs, Zod schemas, MSW mocks, and other assets separately.&lt;/p&gt;

&lt;p&gt;I did not hit major setup issues.&lt;/p&gt;

&lt;p&gt;But if you care about auth, refresh, or error handling, you should understand &lt;code&gt;override.mutator&lt;/code&gt; early.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubb
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Kubb&lt;/code&gt; configuration is plugin-based.&lt;/p&gt;

&lt;p&gt;Depending on your environment, generated imports with &lt;code&gt;.ts&lt;/code&gt; extensions may require this tsconfig setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&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;span class="nl"&gt;"allowImportingTsExtensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not necessarily bad, but it should match your TypeScript and bundler policy.&lt;/p&gt;

&lt;p&gt;I had to adjust the setup a few times.&lt;/p&gt;




&lt;h2&gt;
  
  
  🗨️ My impression of openapi-typescript
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;openapi-typescript&lt;/code&gt; is the thinnest and fastest option.&lt;/p&gt;

&lt;p&gt;It generates types and lets you choose the runtime layer yourself, often with &lt;code&gt;openapi-fetch&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What I liked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;very fast generation&lt;/li&gt;
&lt;li&gt;simple setup&lt;/li&gt;
&lt;li&gt;small generated output&lt;/li&gt;
&lt;li&gt;strong focus on type correctness&lt;/li&gt;
&lt;li&gt;runtime is separate and flexible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What may be missing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no generated SDK functions&lt;/li&gt;
&lt;li&gt;endpoint calls are path-string based&lt;/li&gt;
&lt;li&gt;weaker fit for operationId-based rename refactoring&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TanStack Query&lt;/code&gt;, &lt;code&gt;Zod&lt;/code&gt;, and &lt;code&gt;MSW&lt;/code&gt; need separate handling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you value simplicity, this is very strong.&lt;/p&gt;

&lt;p&gt;Especially if codegen runs often in local development or CI, the speed is a real advantage.&lt;/p&gt;




&lt;h2&gt;
  
  
  🗨️ My impression of hey-api
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;hey-api&lt;/code&gt; felt like the best balance for my use case.&lt;/p&gt;

&lt;p&gt;It is thicker than &lt;code&gt;openapi-typescript&lt;/code&gt;, but much lighter than Orval or Kubb.&lt;/p&gt;

&lt;p&gt;What I liked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;generated SDK functions&lt;/li&gt;
&lt;li&gt;consistent &lt;code&gt;{ query, path, body }&lt;/code&gt; arguments&lt;/li&gt;
&lt;li&gt;axios-like interceptors&lt;/li&gt;
&lt;li&gt;result model and &lt;code&gt;throwOnError&lt;/code&gt; option&lt;/li&gt;
&lt;li&gt;shared schema types are easy to use&lt;/li&gt;
&lt;li&gt;generated output remains manageable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Weak points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;generation takes longer than &lt;code&gt;openapi-typescript&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sdk.gen.ts&lt;/code&gt; can become large&lt;/li&gt;
&lt;li&gt;weaker than Orval / Kubb for &lt;code&gt;MSW&lt;/code&gt; and &lt;code&gt;Faker&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NullEnum&lt;/code&gt; may degrade into an empty enum in some cases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my case, &lt;code&gt;NullEnum&lt;/code&gt; was not a major practical issue because nullable fields still carried &lt;code&gt;nullable: true&lt;/code&gt; through the composed type.&lt;/p&gt;

&lt;p&gt;More importantly, &lt;code&gt;BlankEnum&lt;/code&gt; preserved the empty string correctly.&lt;/p&gt;

&lt;p&gt;For my needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I wanted operationId functions&lt;/li&gt;
&lt;li&gt;I wanted interceptors&lt;/li&gt;
&lt;li&gt;I wanted result-style handling&lt;/li&gt;
&lt;li&gt;I did not need generated MSW or Faker yet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So hey-api was the most practical choice.&lt;/p&gt;




&lt;h2&gt;
  
  
  🗨️ My impression of Orval
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Orval&lt;/code&gt; is strong if you want OpenAPI to generate a broader frontend layer.&lt;/p&gt;

&lt;p&gt;What I liked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;supports &lt;code&gt;TanStack Query&lt;/code&gt;, &lt;code&gt;SWR&lt;/code&gt;, and &lt;code&gt;Vue Query&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;can generate &lt;code&gt;Zod&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;can generate &lt;code&gt;MSW&lt;/code&gt; and &lt;code&gt;Faker&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;can split outputs by target&lt;/li&gt;
&lt;li&gt;faster than Kubb in this test&lt;/li&gt;
&lt;li&gt;shared body schemas are easy to reuse&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Things to watch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;default fetch client does not throw on 4xx / 5xx&lt;/li&gt;
&lt;li&gt;auth and refresh usually require a mutator&lt;/li&gt;
&lt;li&gt;file count can be large&lt;/li&gt;
&lt;li&gt;tag files can become large&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NullEnum&lt;/code&gt; may degrade into an empty const&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you only want an API client, Orval may feel too thick.&lt;/p&gt;

&lt;p&gt;But if you want generated hooks, validation schemas, mocks, and test data, Orval becomes much more attractive.&lt;/p&gt;

&lt;p&gt;If I moved deeper into the ecosystem-generation side, I would probably evaluate Orval first.&lt;/p&gt;




&lt;h2&gt;
  
  
  🗨️ My impression of Kubb
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Kubb&lt;/code&gt; has a strong philosophy.&lt;/p&gt;

&lt;p&gt;What I liked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;close to 1 operation = 1 file&lt;/li&gt;
&lt;li&gt;friendly to grep and physical file navigation&lt;/li&gt;
&lt;li&gt;structure can be tree-shake friendly&lt;/li&gt;
&lt;li&gt;clear plugin-based architecture&lt;/li&gt;
&lt;li&gt;can expand into &lt;code&gt;MSW&lt;/code&gt;, &lt;code&gt;Faker&lt;/code&gt;, &lt;code&gt;TanStack Query&lt;/code&gt;, &lt;code&gt;Zod&lt;/code&gt;, etc.&lt;/li&gt;
&lt;li&gt;gives strong control over the codegen pipeline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What felt heavy in this schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;slow generation&lt;/li&gt;
&lt;li&gt;3,877 files / 24 MB output&lt;/li&gt;
&lt;li&gt;many lint errors from &lt;code&gt;any&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;possible &lt;code&gt;.ts&lt;/code&gt; import extension requirements&lt;/li&gt;
&lt;li&gt;positional path params make wrappers harder&lt;/li&gt;
&lt;li&gt;body types are endpoint-scoped&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BlankEnum&lt;/code&gt; became &lt;code&gt;never&lt;/code&gt; and lost the empty string&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;Kubb&lt;/code&gt; is not a bad tool.&lt;/p&gt;

&lt;p&gt;Actually, I like its design philosophy.&lt;/p&gt;

&lt;p&gt;But if your main goal is simply "I want a type-safe API client for a normal frontend app," it may be too heavy as the first option.&lt;/p&gt;

&lt;p&gt;I would choose Kubb when I have a clear reason, such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I strongly want 1 operation = 1 file&lt;/li&gt;
&lt;li&gt;I want to design my own codegen pipeline&lt;/li&gt;
&lt;li&gt;I want plugin-level control&lt;/li&gt;
&lt;li&gt;I want to unify clients, schemas, mocks, and test data through plugins&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The potential is high, but it needs commitment.&lt;/p&gt;




&lt;h2&gt;
  
  
  📓 Summary table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;hey-api&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Family&lt;/td&gt;
&lt;td&gt;A: type-focused&lt;/td&gt;
&lt;td&gt;A: lightweight SDK&lt;/td&gt;
&lt;td&gt;B: frontend asset generation&lt;/td&gt;
&lt;td&gt;B: plugin/codegen platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation time&lt;/td&gt;
&lt;td&gt;1.5s&lt;/td&gt;
&lt;td&gt;8.0s&lt;/td&gt;
&lt;td&gt;5.5s&lt;/td&gt;
&lt;td&gt;18.1s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output files&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;2,719&lt;/td&gt;
&lt;td&gt;3,877&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output size&lt;/td&gt;
&lt;td&gt;2.4 MB&lt;/td&gt;
&lt;td&gt;2.9 MB&lt;/td&gt;
&lt;td&gt;14 MB&lt;/td&gt;
&lt;td&gt;24 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generates functions&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Function split&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;one SDK file&lt;/td&gt;
&lt;td&gt;one file per tag&lt;/td&gt;
&lt;td&gt;one file per operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error model&lt;/td&gt;
&lt;td&gt;result union&lt;/td&gt;
&lt;td&gt;result union / throw&lt;/td&gt;
&lt;td&gt;success wrapper&lt;/td&gt;
&lt;td&gt;throw&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client object&lt;/td&gt;
&lt;td&gt;external library&lt;/td&gt;
&lt;td&gt;generated client&lt;/td&gt;
&lt;td&gt;raw fetch / mutator&lt;/td&gt;
&lt;td&gt;generated client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interceptor&lt;/td&gt;
&lt;td&gt;openapi-fetch middleware&lt;/td&gt;
&lt;td&gt;axios-like&lt;/td&gt;
&lt;td&gt;mutator&lt;/td&gt;
&lt;td&gt;config / custom client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path params&lt;/td&gt;
&lt;td&gt;&lt;code&gt;params.path&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ path: { id } }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{ id }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;positional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Body type&lt;/td&gt;
&lt;td&gt;shared schema&lt;/td&gt;
&lt;td&gt;shared schema&lt;/td&gt;
&lt;td&gt;shared schema&lt;/td&gt;
&lt;td&gt;endpoint-scoped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TanStack Query&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;plugin&lt;/td&gt;
&lt;td&gt;built-in support&lt;/td&gt;
&lt;td&gt;plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zod&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;plugin&lt;/td&gt;
&lt;td&gt;built-in support&lt;/td&gt;
&lt;td&gt;plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MSW / Faker&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;limited&lt;/td&gt;
&lt;td&gt;strong&lt;/td&gt;
&lt;td&gt;strong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lint&lt;/td&gt;
&lt;td&gt;minor adjustment&lt;/td&gt;
&lt;td&gt;minor adjustment&lt;/td&gt;
&lt;td&gt;minor adjustment&lt;/td&gt;
&lt;td&gt;likely needs ignore/override&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drf-spectacular BlankEnum&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;td&gt;needs care&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;thin and safe&lt;/td&gt;
&lt;td&gt;SDK + interceptors&lt;/td&gt;
&lt;td&gt;generate frontend assets&lt;/td&gt;
&lt;td&gt;design codegen pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  🤔 How I would choose
&lt;/h2&gt;

&lt;p&gt;After this comparison, I would choose like this.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Start with openapi-typescript
&lt;/h3&gt;

&lt;p&gt;If your project already works well with &lt;code&gt;openapi-typescript + openapi-fetch&lt;/code&gt;, I would stay there.&lt;/p&gt;

&lt;p&gt;It is fast, thin, and easy to understand.&lt;/p&gt;

&lt;p&gt;A small wrapper like &lt;code&gt;src/api/client.ts&lt;/code&gt; plus middleware-based auth may be enough.&lt;/p&gt;

&lt;p&gt;There is no need to move to heavier codegen unless you have a concrete pain.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Move to hey-api when SDK functions become useful
&lt;/h3&gt;

&lt;p&gt;The next step is usually wanting things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;call APIs by operationId functions instead of URL strings&lt;/li&gt;
&lt;li&gt;grep by function name&lt;/li&gt;
&lt;li&gt;use rename refactoring&lt;/li&gt;
&lt;li&gt;handle auth and refresh with interceptors&lt;/li&gt;
&lt;li&gt;work with consistent endpoint options&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At that stage, &lt;code&gt;hey-api&lt;/code&gt; is a very good fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Use Orval when OpenAPI becomes the source of frontend assets
&lt;/h3&gt;

&lt;p&gt;If OpenAPI becomes more than API client generation, &lt;code&gt;Orval&lt;/code&gt; becomes strong.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;generated &lt;code&gt;TanStack Query&lt;/code&gt; hooks&lt;/li&gt;
&lt;li&gt;generated &lt;code&gt;Zod&lt;/code&gt; schemas&lt;/li&gt;
&lt;li&gt;generated &lt;code&gt;MSW&lt;/code&gt; mocks&lt;/li&gt;
&lt;li&gt;generated fake data&lt;/li&gt;
&lt;li&gt;multiple generation targets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where Orval's value becomes much clearer.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Choose Kubb when you love the architecture
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Kubb&lt;/code&gt; is powerful.&lt;/p&gt;

&lt;p&gt;But at this schema size, it was also heavy.&lt;/p&gt;

&lt;p&gt;I would choose it when I strongly care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 operation = 1 file&lt;/li&gt;
&lt;li&gt;plugin architecture&lt;/li&gt;
&lt;li&gt;tree-shake-friendly structure&lt;/li&gt;
&lt;li&gt;codegen pipeline control&lt;/li&gt;
&lt;li&gt;generating many kinds of artifacts consistently&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It may not be my first choice for a normal frontend app, but it is very interesting if you want to design codegen itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 Final conclusion
&lt;/h2&gt;

&lt;p&gt;OpenAPI codegen should not be chosen only by asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which tool has the most features?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A better question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How much generated code are we willing to own?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My final choice looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;I only want types&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openapi-typescript&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I want SDK functions and interceptors&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hey-api&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I want to generate frontend assets together&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Orval&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I want to control codegen structure and plugins&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Kubb&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For my current project, &lt;code&gt;hey-api&lt;/code&gt; had the best balance of SDK functions, interceptors, result handling, and output size.&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.amazonaws.com%2Fuploads%2Farticles%2Fi70tk9kuqwxr09haz4hg.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.amazonaws.com%2Fuploads%2Farticles%2Fi70tk9kuqwxr09haz4hg.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The more useful codegen becomes, the bigger the generated forest becomes.&lt;/p&gt;

&lt;p&gt;So the important question is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who maintains that forest, how much of it, and with what team rules?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the real OpenAPI codegen decision.&lt;/p&gt;

&lt;p&gt;What does your team generate from OpenAPI?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only types?&lt;/li&gt;
&lt;li&gt;SDK functions?&lt;/li&gt;
&lt;li&gt;Hooks?&lt;/li&gt;
&lt;li&gt;Mocks?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;or the whole forest? 🌳🌳🌳&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>openapi</category>
      <category>frontend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why I Didn’t Let AI Handle My Scroll Animation: Astro, React, and TypeScript Architecture</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Thu, 07 May 2026 12:41:31 +0000</pubDate>
      <link>https://dev.to/nyaomaru/why-i-didnt-let-ai-handle-my-scroll-animation-astro-react-and-typescript-architecture-37k2</link>
      <guid>https://dev.to/nyaomaru/why-i-didnt-let-ai-handle-my-scroll-animation-astro-react-and-typescript-architecture-37k2</guid>
      <description>&lt;p&gt;Hoi hoi!&lt;/p&gt;

&lt;p&gt;I'm &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;@nyaomaru&lt;/a&gt;, a frontend engineer who once panicked because I triggered a fire alarm while roasting chashu at home in the Netherlands. 🐖&lt;/p&gt;

&lt;p&gt;This time, I want to write about how I built the corporate website for &lt;strong&gt;Necoz B.V.&lt;/strong&gt;&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://necoz.co/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fnecoz.co%2Fnecoz_ogp.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://necoz.co/" rel="noopener noreferrer" class="c-link"&gt;
            Necoz | Software Development Studio in Amsterdam
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Necoz B.V. is a software development studio based in Amsterdam, the Netherlands. We build well-designed systems, clean architecture, and scalable web applications.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fnecoz.co%2Ffavicon.png"&gt;
          necoz.co
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The site is built with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Astro&lt;/code&gt; for the base structure&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;React&lt;/code&gt; only where interactive UI is needed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TypeScript&lt;/code&gt; for the main animation control&lt;/li&gt;
&lt;li&gt;responsive behavior for both desktop and mobile&lt;/li&gt;
&lt;li&gt;virtual scrolling to control the scroll amount itself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because this is a corporate website, I did not want to break SEO.&lt;/p&gt;

&lt;p&gt;But I also wanted the motion to feel good.&lt;/p&gt;

&lt;p&gt;And I did not want the experience to fall apart on mobile.&lt;/p&gt;

&lt;p&gt;When you take these requirements seriously, the important question is not only:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which technology should I use?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It becomes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which responsibility should belong to which layer?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the end, the hardest part was the animation.&lt;/p&gt;

&lt;p&gt;AI was useful. It helped me move fast. It gave me rough ideas and initial implementations.&lt;/p&gt;

&lt;p&gt;But the animation it produced was not something I could use as-is.&lt;/p&gt;

&lt;p&gt;This article is about why, and how I ended up designing the animation system.&lt;/p&gt;

&lt;p&gt;The repository is here:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;
        nyaomaru
      &lt;/a&gt; / &lt;a href="https://github.com/nyaomaru/necoz" rel="noopener noreferrer"&gt;
        necoz
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Official Necoz B.V. website with custom scroll animations.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;necoz&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/nyaomaru/necoz/main/public/necoz_ogp.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnyaomaru%2Fnecoz%2Fmain%2Fpublic%2Fnecoz_ogp.png" alt="necoz logo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Necoz B.V. website built with Astro, React islands, and custom scroll / walker animation logic.&lt;/p&gt;
&lt;p&gt;Scroll down and enjoy the animations!&lt;/p&gt;
&lt;p&gt;You can check it on mobile too 😸&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Project Structure&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;The project is organized like this:&lt;/p&gt;
&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;/
├── public/                     # Static files served as-is
├── src/
│   ├── components/
│   │   ├── home/               # Homepage sections
│   │   └── ui/                 # Reusable UI primitives and shared shells
│   │       ├── block/
│   │       ├── button/
│   │       ├── layout/
│   │       ├── mail/
│   │       ├── nyaomaru/
│   │       ├── scene/
│   │       ├── scroll/
│   │       └── text/
│   ├── layouts/                # Shared page layout shells
│   ├── lib/                    # Non-visual shared logic
│   │   ├── nyaomaru/           # Walker controller, scene logic, scene models
│   │   ├── math.ts
│   │   └── site-links.ts
│   ├── pages/                  # Route entrypoints
│   │   ├── index.astro
│   │   └── privacy-policy.astro
│&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/nyaomaru/necoz" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Let's get into it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🤔 Why I did not use AI-generated animation as-is
&lt;/h2&gt;

&lt;p&gt;AI-generated animation often looks nice at first glance.&lt;/p&gt;

&lt;p&gt;It usually has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;some movement&lt;/li&gt;
&lt;li&gt;some easing&lt;/li&gt;
&lt;li&gt;some visual atmosphere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But when I looked more carefully, I often felt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The direction is right, but the timing is wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the movement was too fast&lt;/li&gt;
&lt;li&gt;everything moved with the same rhythm&lt;/li&gt;
&lt;li&gt;elements did not stop where they should&lt;/li&gt;
&lt;li&gt;there was no pause&lt;/li&gt;
&lt;li&gt;there was no sense of timing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI can generate the rough idea of "how something moves".&lt;/p&gt;

&lt;p&gt;But in real animation, what matters is also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;where it stops&lt;/li&gt;
&lt;li&gt;where it waits a little&lt;/li&gt;
&lt;li&gt;where one element reacts slightly later&lt;/li&gt;
&lt;li&gt;where the rhythm changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Animation is not just about moving from position A to position B.&lt;/p&gt;

&lt;p&gt;A small pause, a tiny delay, or a slightly different easing curve can completely change how it feels.&lt;/p&gt;

&lt;p&gt;I think this is one of the current gaps between AI and human judgment:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;AI can produce motion quickly, but humans still have better sensitivity for timing and rhythm.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I used AI for rough drafts, structure, and experiments.&lt;/p&gt;

&lt;p&gt;But I adjusted the final timing by hand.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧑‍🚀 Why Astro?
&lt;/h2&gt;

&lt;p&gt;This site is a corporate website.&lt;/p&gt;

&lt;p&gt;So I wanted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;real HTML content&lt;/li&gt;
&lt;li&gt;good SEO&lt;/li&gt;
&lt;li&gt;fast initial loading&lt;/li&gt;
&lt;li&gt;no unnecessary SPA architecture&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why I chose &lt;code&gt;Astro&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://astro.build/" rel="noopener noreferrer"&gt;https://astro.build/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The page structure is mainly built around &lt;code&gt;src/pages/index.astro&lt;/code&gt; and &lt;code&gt;src/layouts/Layout.astro&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The header, sections, footer, and base layout are handled by Astro.&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;Layout&lt;/span&gt; &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;{DEFAULT_TITLE}&lt;/span&gt; &lt;span class="na"&gt;description=&lt;/span&gt;&lt;span class="s"&gt;{DEFAULT_DESCRIPTION}&lt;/span&gt; &lt;span class="na"&gt;canonicalPath=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;main&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"top"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;Header&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;HeroIntro&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;WorkSection&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;StudioSection&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;ContactSection&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;NyaomaruWalker&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/main&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Footer&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Layout&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The layout also switches between native scrolling and virtual scrolling.&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;body&lt;/span&gt; &lt;span class="na"&gt;class:list=&lt;/span&gt;&lt;span class="s"&gt;{[virtualScroll&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="na"&gt;virtual-scroll-body&lt;/span&gt;&lt;span class="err"&gt;"&lt;/span&gt; &lt;span class="na"&gt;:&lt;/span&gt; &lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="na"&gt;native-scroll-body&lt;/span&gt;&lt;span class="err"&gt;"]}&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  {virtualScroll ? &lt;span class="nt"&gt;&amp;lt;VirtualScroll&amp;gt;&amp;lt;slot&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&amp;lt;/VirtualScroll&amp;gt;&lt;/span&gt; : &lt;span class="nt"&gt;&amp;lt;slot&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;}
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;What I like about &lt;code&gt;Astro&lt;/code&gt; is that I do not have to turn the whole site into JavaScript.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Astro&lt;/code&gt; lets me output clean HTML first, and then hydrate only the parts that need client-side behavior.&lt;/p&gt;

&lt;p&gt;For a corporate website, this is very useful.&lt;/p&gt;

&lt;p&gt;In this project, I wanted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;solid page structure&lt;/li&gt;
&lt;li&gt;metadata and SEO&lt;/li&gt;
&lt;li&gt;client-side animation only where necessary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;Astro&lt;/code&gt; did not make the animation better by itself.&lt;/p&gt;

&lt;p&gt;But it helped me separate structure and behavior cleanly.&lt;/p&gt;

&lt;p&gt;That separation was very important.&lt;/p&gt;


&lt;h2&gt;
  
  
  ⚖️ I used React, but not for everything
&lt;/h2&gt;

&lt;p&gt;The site also uses &lt;code&gt;React&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But it is not a full React app.&lt;/p&gt;

&lt;p&gt;The responsibility is divided like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Astro&lt;/code&gt; owns the page structure&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;React&lt;/code&gt; owns small interactive UI parts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TypeScript&lt;/code&gt; owns the animation system&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, the mail dialog is hydrated with &lt;code&gt;client:load&lt;/code&gt;.&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;"contact-links"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;MailDialogTrigger&lt;/span&gt; &lt;span class="na"&gt;client:load&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;SocialLinks&lt;/span&gt; &lt;span class="nt"&gt;/&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;The dialog trigger itself is a React component.&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;MailDialogTrigger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsOpen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;previousOverflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return &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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;previousOverflow&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isOpen&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="c1"&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 a good use case for &lt;code&gt;React&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is local UI state.&lt;/p&gt;

&lt;p&gt;There is user interaction.&lt;/p&gt;

&lt;p&gt;There is a dialog.&lt;/p&gt;

&lt;p&gt;But that does not mean the entire website needs to become a React application.&lt;/p&gt;

&lt;p&gt;This was an important decision:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Using React does not mean everything has to be React.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;React is great for UI.&lt;/p&gt;

&lt;p&gt;But I did not want React to become responsible for the motion system.&lt;/p&gt;


&lt;h2&gt;
  
  
  🌊 Animation is controlled by TypeScript, not React state
&lt;/h2&gt;

&lt;p&gt;The core animation logic lives under &lt;code&gt;src/lib/nyaomaru/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It handles things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the walking character&lt;/li&gt;
&lt;li&gt;scene progression&lt;/li&gt;
&lt;li&gt;scroll-based state updates&lt;/li&gt;
&lt;li&gt;DOM measurement&lt;/li&gt;
&lt;li&gt;animation phases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of the main files are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;walker-controller.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Controls the main walker progression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;work-scene.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Controls the work section scene&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;studio-scene.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Controls the studio section scene&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact-scene.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Controls the contact section scene&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is not a purely declarative animation system.&lt;/p&gt;

&lt;p&gt;It is much more direct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;read the current scroll position&lt;/li&gt;
&lt;li&gt;determine the active scene&lt;/li&gt;
&lt;li&gt;calculate progress&lt;/li&gt;
&lt;li&gt;calculate the pose&lt;/li&gt;
&lt;li&gt;update the DOM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simplified version looks like this:&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;update&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scrollY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getSceneScrollY&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;activeSceneState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getActiveSceneState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollY&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;activeSceneState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;progress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getSceneProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scrollY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activeSceneState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapshot&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;pose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;activeSceneState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scene&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;activeSceneState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;walker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`translate(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;walker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pose&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;;&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;requestUpdate&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;cancelAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rafId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;rafId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;update&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 flow is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;read the scroll value&lt;/li&gt;
&lt;li&gt;find the active scene&lt;/li&gt;
&lt;li&gt;calculate progress&lt;/li&gt;
&lt;li&gt;calculate the pose&lt;/li&gt;
&lt;li&gt;apply it to the DOM&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This was easier to tune than putting everything into React state.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;React&lt;/code&gt; is strong for UI state.&lt;/p&gt;

&lt;p&gt;But animation timing often needs a thinner, more direct control layer.&lt;/p&gt;

&lt;p&gt;This does not mean React is bad for animation.&lt;/p&gt;

&lt;p&gt;It only means that, for this project, the responsibilities were different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React was for UI&lt;/li&gt;
&lt;li&gt;TypeScript was for motion&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That separation worked well.&lt;/p&gt;


&lt;h2&gt;
  
  
  💎 Why I did not avoid direct DOM manipulation
&lt;/h2&gt;

&lt;p&gt;In modern frontend development, we often try to avoid direct DOM manipulation.&lt;/p&gt;

&lt;p&gt;Usually, I agree with that direction.&lt;/p&gt;

&lt;p&gt;Declarative UI is easier to reason about in many cases.&lt;/p&gt;

&lt;p&gt;But scroll-driven animation is a little different.&lt;/p&gt;

&lt;p&gt;Sometimes, the important state is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What state does this component have?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What is actually visible on the screen right now?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For this project, I needed to measure things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;getBoundingClientRect()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;viewport width&lt;/li&gt;
&lt;li&gt;which block is currently visible&lt;/li&gt;
&lt;li&gt;whether the desktop or mobile layout is active&lt;/li&gt;
&lt;li&gt;where the walker is currently positioned&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This was similar to what I learned while building a browser game before:&lt;/p&gt;


&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/nyaomaru/building-a-browser-game-with-react-what-doesnt-work-well-run-away-from-work-1khf" class="crayons-story__hidden-navigation-link"&gt;Building a Browser Game with React: What Doesn’t work well (Run Away From Work)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/nyaomaru" class="crayons-avatar  crayons-avatar--l  "&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1436158%2Fa78bc4ed-7796-4760-baa5-06283c2404d6.png" alt="nyaomaru profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/nyaomaru" class="crayons-story__secondary fw-medium m:hidden"&gt;
              nyaomaru
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                nyaomaru
                
              
              &lt;div id="story-author-preview-content-3466423" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/nyaomaru" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1436158%2Fa78bc4ed-7796-4760-baa5-06283c2404d6.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;nyaomaru&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/nyaomaru/building-a-browser-game-with-react-what-doesnt-work-well-run-away-from-work-1khf" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Apr 8&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/nyaomaru/building-a-browser-game-with-react-what-doesnt-work-well-run-away-from-work-1khf" id="article-link-3466423"&gt;
          Building a Browser Game with React: What Doesn’t work well (Run Away From Work)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/gamedev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;gamedev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/typescript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;typescript&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/react"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;react&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/nyaomaru/building-a-browser-game-with-react-what-doesnt-work-well-run-away-from-work-1khf" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;39&lt;span class="hidden s:inline"&gt; reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/nyaomaru/building-a-browser-game-with-react-what-doesnt-work-well-run-away-from-work-1khf#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              3&lt;span class="hidden s:inline"&gt; comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;The animation depends on the actual layout.&lt;/p&gt;

&lt;p&gt;So the DOM is not just an output target.&lt;/p&gt;

&lt;p&gt;It is also something the animation system has to observe.&lt;/p&gt;

&lt;p&gt;In this case, directly measuring the DOM made the intention clearer.&lt;/p&gt;

&lt;p&gt;It also avoided unnecessary indirection.&lt;/p&gt;

&lt;p&gt;So I did not avoid direct DOM manipulation completely.&lt;/p&gt;

&lt;p&gt;I used it only where it made sense.&lt;/p&gt;




&lt;h2&gt;
  
  
  😿 Responsive design was not only a CSS problem
&lt;/h2&gt;

&lt;p&gt;The site also supports mobile.&lt;/p&gt;

&lt;p&gt;But responsive behavior here was not only about changing layout with CSS.&lt;/p&gt;

&lt;p&gt;For several sections, I have different block structures for desktop and mobile.&lt;/p&gt;

&lt;p&gt;For example, the hero, work, and studio sections have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;desktop blocks&lt;/li&gt;
&lt;li&gt;mobile blocks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The visible block changes depending on the viewport.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;TypeScript&lt;/code&gt; animation logic also uses a &lt;code&gt;430px&lt;/code&gt; mobile breakpoint and changes things like scene progress, landing positions, and offsets.&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;workStack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isMobileViewport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;getVisibleElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-work-mobile-origin]&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;span class="nf"&gt;getVisibleElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[data-work-scene-display]&lt;/span&gt;&lt;span class="dl"&gt;"&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;stackRect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;workStack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&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;walkerRect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;walker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&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;shotX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;walkerRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;right&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isMobileViewport&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;MOBILE_SHOT_OFFSET_X&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SHOT_OFFSET_X&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The markup also separates desktop and mobile 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;"block block-three block-three--desktop"&lt;/span&gt;&lt;span class="nt"&gt;&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;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"block block-three block-three--mobile"&lt;/span&gt;&lt;span class="nt"&gt;&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;So responsive design in this project affected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DOM structure&lt;/li&gt;
&lt;li&gt;scroll progression&lt;/li&gt;
&lt;li&gt;animation targets&lt;/li&gt;
&lt;li&gt;movement distance&lt;/li&gt;
&lt;li&gt;position correction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I handled only the visual layout, the desktop version might feel good, but the mobile version would break quickly.&lt;/p&gt;

&lt;p&gt;For scroll-driven animation, responsive design is not only layout adjustment.&lt;/p&gt;

&lt;p&gt;It is also motion redesign.&lt;/p&gt;




&lt;h2&gt;
  
  
  📏 Why I used virtual scroll
&lt;/h2&gt;

&lt;p&gt;One of the most important parts of this site is virtual scrolling.&lt;/p&gt;

&lt;p&gt;The layout uses &lt;code&gt;VirtualScroll.astro&lt;/code&gt;, and inside that layer, a custom scroll controller runs.&lt;/p&gt;

&lt;p&gt;The goal was not just to make scrolling look fancy.&lt;/p&gt;

&lt;p&gt;The real goal was to separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;visual scroll&lt;/li&gt;
&lt;li&gt;scene scroll&lt;/li&gt;
&lt;li&gt;scrollbar behavior&lt;/li&gt;
&lt;li&gt;smooth scroll following&lt;/li&gt;
&lt;li&gt;animation progression&lt;/li&gt;
&lt;/ul&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.amazonaws.com%2Fuploads%2Farticles%2F4s4wn0ozo2q1thg9pfbw.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.amazonaws.com%2Fuploads%2Farticles%2F4s4wn0ozo2q1thg9pfbw.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the implementation, I separate &lt;code&gt;visualScrollY&lt;/code&gt; and &lt;code&gt;sceneScrollY&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;applyScroll&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visualScrollY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getVisualScrollY&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;sceneScrollY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getSceneScrollY&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;visualScrollY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;setScrollState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sceneScrollY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visualScrollY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CustomEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;necoz:virtual-scroll&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;The scroll values are read like this:&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;getVisualScrollY&lt;/span&gt; &lt;span class="o"&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__necozScrollY&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollY&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;getSceneScrollY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;visualScrollY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getVisualScrollY&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__necozSceneScrollY&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;visualScrollY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This separation was very important.&lt;/p&gt;

&lt;p&gt;If everything depends directly on native scroll, it becomes harder to control the rhythm.&lt;/p&gt;

&lt;p&gt;For example, I wanted to adjust cases like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the page visually moved down, but the scene should wait a little more&lt;/li&gt;
&lt;li&gt;the footer area needs denser animation&lt;/li&gt;
&lt;li&gt;mobile should use a different scroll progression&lt;/li&gt;
&lt;li&gt;some scenes should feel slower or more deliberate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With virtual scroll, I can separate:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;how much the page visually moves&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;from:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;how much the animation scene progresses&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That means I can design the time axis myself.&lt;/p&gt;

&lt;p&gt;This was the main reason I used virtual scroll.&lt;/p&gt;

&lt;p&gt;Not because it looks cool.&lt;/p&gt;

&lt;p&gt;But because I wanted control over animation time.&lt;/p&gt;

&lt;p&gt;For scroll-driven animation, scroll is not just movement.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scroll is input.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And if scroll is input, it needs to be designed carefully.&lt;/p&gt;




&lt;h2&gt;
  
  
  🤖 Where AI was useful
&lt;/h2&gt;

&lt;p&gt;At this point, it might sound like I do not trust AI.&lt;/p&gt;

&lt;p&gt;That is not true.&lt;/p&gt;

&lt;p&gt;AI was very useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;generating rough drafts&lt;/li&gt;
&lt;li&gt;trying implementation patterns&lt;/li&gt;
&lt;li&gt;splitting responsibilities&lt;/li&gt;
&lt;li&gt;getting something working quickly&lt;/li&gt;
&lt;li&gt;exploring ideas before committing to one direction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As a tool for the first step, AI is excellent.&lt;/p&gt;

&lt;p&gt;But final animation tuning was different.&lt;/p&gt;

&lt;p&gt;For example, walking, falling, and landing phases were separated like this:&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;lerp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;progress&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;progress&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;secondFallStart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secondFallX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;landingX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secondFallProgress&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secondRunY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;landingY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secondFallProgress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;HERO_FALL_EASING_POWER&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;second-fall&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;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;progress&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;secondRunStart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;lerp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstFallX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secondFallX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secondRunProgress&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secondRunY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;second-run&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This kind of adjustment required a repeated loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;look at the animation&lt;/li&gt;
&lt;li&gt;feel that something is slightly wrong&lt;/li&gt;
&lt;li&gt;adjust a function or a number&lt;/li&gt;
&lt;li&gt;check it again&lt;/li&gt;
&lt;li&gt;repeat&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AI helped me move fast.&lt;/p&gt;

&lt;p&gt;But speed alone was not enough.&lt;/p&gt;

&lt;p&gt;For this kind of site, the character walks, the scene changes, and the rhythm follows the scroll.&lt;/p&gt;

&lt;p&gt;The final feeling had to be adjusted by hand.&lt;/p&gt;

&lt;p&gt;I think this is one of the areas where frontend engineers will still matter in the AI era.&lt;/p&gt;

&lt;p&gt;Not only writing code faster.&lt;/p&gt;

&lt;p&gt;But deciding whether the result actually feels good.&lt;/p&gt;




&lt;h2&gt;
  
  
  💖 What I learned from this architecture
&lt;/h2&gt;

&lt;p&gt;The main lessons from this project are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;separate HTML structure and animation behavior&lt;/li&gt;
&lt;li&gt;separate UI state and animation state&lt;/li&gt;
&lt;li&gt;treat scroll as input, not just movement&lt;/li&gt;
&lt;li&gt;responsive animation requires motion redesign, not only layout changes&lt;/li&gt;
&lt;li&gt;direct DOM measurement can be valid when the screen itself is part of the state&lt;/li&gt;
&lt;li&gt;AI is useful for speed, but timing and rhythm still need human judgment&lt;/li&gt;
&lt;/ul&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.amazonaws.com%2Fuploads%2Farticles%2F3zdm6v4aoj0j3t3uktwm.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.amazonaws.com%2Fuploads%2Farticles%2F3zdm6v4aoj0j3t3uktwm.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For scroll-driven animation, this kind of separation made the system much easier to reason about.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 Summary
&lt;/h2&gt;

&lt;p&gt;For the Necoz B.V. website, I used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Astro&lt;/code&gt; for page structure and initial HTML&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;React&lt;/code&gt; for small interactive UI parts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TypeScript&lt;/code&gt; for the main animation control&lt;/li&gt;
&lt;li&gt;direct DOM measurement where needed&lt;/li&gt;
&lt;li&gt;responsive motion logic for desktop and mobile&lt;/li&gt;
&lt;li&gt;virtual scroll to redesign scroll progression&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest benefit was that I could keep SEO and initial rendering stable while still keeping control over the motion system.&lt;/p&gt;

&lt;p&gt;AI helped a lot.&lt;/p&gt;

&lt;p&gt;But there is still a gap between:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;animation that moves&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;animation that feels alive&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I did not let AI handle everything.&lt;/p&gt;

&lt;p&gt;I used it to move faster, organize ideas, and create rough versions.&lt;/p&gt;

&lt;p&gt;But I kept the final timing and rhythm in my own hands.&lt;/p&gt;

&lt;p&gt;In the end, I think what I was really building was not just a layout or an animation.&lt;/p&gt;

&lt;p&gt;I was designing time along the scroll.&lt;/p&gt;

&lt;p&gt;Thanks for reading!&lt;/p&gt;

&lt;p&gt;If you found this interesting, the repository is public.&lt;/p&gt;

&lt;p&gt;A star ⭐ would make me very happy 😸&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/necoz" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/necoz&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>astro</category>
      <category>react</category>
    </item>
    <item>
      <title>Handling `unknown` in TypeScript… isn't it painful?</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Tue, 28 Apr 2026 13:09:54 +0000</pubDate>
      <link>https://dev.to/nyaomaru/handling-unknown-in-typescript-isnt-it-painful-4dec</link>
      <guid>https://dev.to/nyaomaru/handling-unknown-in-typescript-isnt-it-painful-4dec</guid>
      <description>&lt;p&gt;Hi there 👋&lt;/p&gt;

&lt;p&gt;I'm a frontend engineer based in the Netherlands, currently suffering from hay fever 😿&lt;/p&gt;

&lt;p&gt;API responses, form inputs, external data…&lt;/p&gt;

&lt;p&gt;In TypeScript, we often end up dealing with &lt;strong&gt;&lt;code&gt;unknown&lt;/code&gt;&lt;/strong&gt;,&lt;br&gt;
and handling it properly can become a real pain in day-to-day work.&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.amazonaws.com%2Fuploads%2Farticles%2F1n1jxhr8qai9f63jr5mi.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.amazonaws.com%2Fuploads%2Farticles%2F1n1jxhr8qai9f63jr5mi.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Yes, &lt;code&gt;unknown&lt;/code&gt; has a kind of &lt;strong&gt;gravitational pull like the universe&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;But still…&lt;br&gt;
we &lt;em&gt;do&lt;/em&gt; want to handle types safely, right?&lt;/p&gt;

&lt;p&gt;That’s where &lt;strong&gt;&lt;code&gt;is-kit&lt;/code&gt;&lt;/strong&gt; comes in a library for composing type guards.&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.amazonaws.com%2Fuploads%2Farticles%2Fah7dsw9wjzpexas5fbuz.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.amazonaws.com%2Fuploads%2Farticles%2Fah7dsw9wjzpexas5fbuz.png" alt=" " width="800" height="572"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;
        nyaomaru
      &lt;/a&gt; / &lt;a href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;
        is-kit
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Lightweight, zero-dependency toolkit for building `isFoo` style type guards in TypeScript. Runtime-safe 🛡️, composable 🧩, and ergonomic ✨. npm -&amp;gt; https://www.npmjs.com/package/is-kit
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;is-kit&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/nyaomaru/is-kit/main/docs/public/iskit_image.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnyaomaru%2Fis-kit%2Fmain%2Fdocs%2Fpublic%2Fiskit_image.png" width="600" alt="is-kit logo"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://www.npmjs.com/package/is-kit" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/cb829c46f855a6f184f255391ea5fe58bb6ed17112e34d4856acd61dc63179c2/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f762f69732d6b69742e737667" alt="npm version"&gt;
  &lt;/a&gt;
  &lt;a href="https://jsr.io/@nyaomaru/is-kit" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/b6648f6bf32eb9dff2136ce8538ccb4cc6af537c1127292b5fb0de416f7a69b4/68747470733a2f2f696d672e736869656c64732e696f2f6a73722f762f406e79616f6d6172752f69732d6b6974" alt="JSR"&gt;
  &lt;/a&gt;
  &lt;a href="https://www.npmjs.com/package/is-kit" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/9b30eeaac46ebced99b5e0dc290fd045bc34c738c66b5d95c4b6ee8fed358b34/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f64742f69732d6b69742e737667" alt="npm downloads"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/nyaomaru/is-kit/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/631cdf8c56ad84dd8657fe3371b93015b5474c517422f968ff4ec944bb28bf2f/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f6c2f69732d6b69742e7376673f73616e6974697a653d74727565" alt="License"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;&lt;code&gt;is-kit&lt;/code&gt; is a lightweight, zero-dependency toolkit for building reusable TypeScript &lt;strong&gt;type guards&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It helps you write small &lt;code&gt;isFoo&lt;/code&gt; functions, compose them into &lt;strong&gt;richer runtime checks&lt;/strong&gt;, and keep &lt;strong&gt;TypeScript narrowing&lt;/strong&gt; natural inside regular control flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runtime-safe&lt;/strong&gt; 🛡️, &lt;strong&gt;composable&lt;/strong&gt; 🧩, and &lt;strong&gt;ergonomic&lt;/strong&gt; ✨ without asking you to adopt a heavy schema workflow.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build and reuse &lt;strong&gt;typed guards&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compose guards&lt;/strong&gt; with &lt;code&gt;and&lt;/code&gt;, &lt;code&gt;or&lt;/code&gt;, &lt;code&gt;not&lt;/code&gt;, &lt;code&gt;oneOf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate object&lt;/strong&gt; shapes and collections&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parse or assert&lt;/strong&gt; &lt;code&gt;unknown&lt;/code&gt; values without a large schema framework&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://is-kit-docs.vercel.app/" rel="nofollow noopener noreferrer"&gt;📚 Documentation Site&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Best for &lt;strong&gt;app-internal narrowing, filtering, and reusable guards&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;🤔 Why use &lt;code&gt;is-kit&lt;/code&gt;?&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Tired of rewriting the same &lt;code&gt;isFoo&lt;/code&gt; checks again and again?&lt;/p&gt;

&lt;p&gt;&lt;code&gt;is-kit&lt;/code&gt; is a good fit when you want to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;write reusable &lt;code&gt;isX&lt;/code&gt;&lt;/strong&gt; functions instead of one-off inline checks&lt;/li&gt;
&lt;li&gt;keep runtime validation &lt;strong&gt;lightweight and dependency-free&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;narrow values directly&lt;/strong&gt; in &lt;code&gt;if&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, and other TypeScript control flow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;compose validation logic&lt;/strong&gt;…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Libraries like &lt;code&gt;Zod&lt;/code&gt; take a &lt;strong&gt;schema-first approach&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In contrast, &lt;strong&gt;&lt;code&gt;is-kit&lt;/code&gt; focuses on narrowing types &lt;em&gt;within your existing code&lt;/em&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of “writing validation”, you can think of it as &lt;strong&gt;extending your everyday &lt;code&gt;if&lt;/code&gt; statements with type safety&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;Zod&lt;/code&gt; is for &lt;strong&gt;boundaries (API / input)&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;is-kit&lt;/code&gt; is for &lt;strong&gt;inside your application logic&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  A simple example
&lt;/h2&gt;

&lt;p&gt;Imagine you need to check “strings with length ≤ 3” multiple times.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;is-kit&lt;/code&gt;, you can define it once and reuse 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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;define&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isString&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-kit&lt;/span&gt;&lt;span class="dl"&gt;"&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;isShortString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;define&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;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;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="nf"&gt;isString&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;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then use it like this:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isShortString&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;~/utils/is&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&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="c1"&gt;// before → repeating conditions every time&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// after → reusable guard&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isShortString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&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;&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.amazonaws.com%2Fuploads%2Farticles%2Fbsw247artoxxa6dbvb9b.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.amazonaws.com%2Fuploads%2Farticles%2Fbsw247artoxxa6dbvb9b.gif" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This style works seamlessly with&lt;br&gt;
&lt;code&gt;if&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;map&lt;/code&gt;, and other existing logic.&lt;/p&gt;


&lt;h2&gt;
  
  
  🐾 How &lt;code&gt;is-kit&lt;/code&gt; has evolved
&lt;/h2&gt;

&lt;p&gt;It’s been about 6 months since the &lt;code&gt;v1.0&lt;/code&gt; release.&lt;/p&gt;

&lt;p&gt;Back then, &lt;code&gt;is-kit&lt;/code&gt; started as a &lt;strong&gt;lightweight type guard library&lt;/strong&gt;&lt;br&gt;
with primitives like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;define&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;and&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;or&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;struct&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;arrayOf&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since then, up to &lt;code&gt;v1.6&lt;/code&gt;, it has gradually evolved into something more practical:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;a toolkit for handling &lt;code&gt;unknown&lt;/code&gt; values in real-world applications&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let’s look at 5 major improvements.&lt;/p&gt;




&lt;h2&gt;
  
  
  🪄 1. Distinguishing “missing” vs “undefined” in &lt;code&gt;struct&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A common situation in API responses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A key is missing entirely&lt;/li&gt;
&lt;li&gt;A key exists but its value is &lt;code&gt;undefined&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are &lt;em&gt;not the same&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;v1.5.0&lt;/code&gt;, &lt;code&gt;optionalKey(...)&lt;/code&gt; was introduced:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;optionalKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-kit&lt;/span&gt;&lt;span class="dl"&gt;"&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;isUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;struct&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;isString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;nickname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;optionalKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;optionalKey&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="nx"&gt;isString&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 allows you to express both cases explicitly.&lt;/p&gt;


&lt;h2&gt;
  
  
  🔑 2. Key-based narrowing (&lt;code&gt;hasKey&lt;/code&gt;, &lt;code&gt;hasKeys&lt;/code&gt;, &lt;code&gt;narrowKeyTo&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;v1.1.13&lt;/code&gt; and &lt;code&gt;v1.4.0&lt;/code&gt;, key-based utilities were added:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;hasKeys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;narrowKeyTo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;oneOfValues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-kit&lt;/span&gt;&lt;span class="dl"&gt;"&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;isUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;struct&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;isString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;oneOfValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&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;guest&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;trial&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasRoleAndId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hasKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;role&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;id&lt;/span&gt;&lt;span class="dl"&gt;"&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;byRole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;narrowKeyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;"&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;isGuest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;byRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guest&lt;/span&gt;&lt;span class="dl"&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 lets you &lt;strong&gt;build on top of existing guards&lt;/strong&gt; instead of redefining everything.&lt;/p&gt;


&lt;h2&gt;
  
  
  🧪 3. &lt;code&gt;assert&lt;/code&gt; for fail-fast validation
&lt;/h2&gt;

&lt;p&gt;Added in &lt;code&gt;v1.2.0&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isString&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-kit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&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="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input must be a string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;You can now use guards in a &lt;strong&gt;fail-fast style&lt;/strong&gt; when needed.&lt;/p&gt;


&lt;h2&gt;
  
  
  ✨ 4. Support for &lt;code&gt;Set&lt;/code&gt; and &lt;code&gt;Map&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;v1.6.0&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setOf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isNumber&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-kit&lt;/span&gt;&lt;span class="dl"&gt;"&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;isTags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isString&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;isScores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isNumber&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Not everything is an array in real-world apps —&lt;br&gt;
this expands coverage to common JS data structures.&lt;/p&gt;


&lt;h2&gt;
  
  
  🥏 5. Handling numeric edge cases
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;v1.1.x&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;isInteger&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isSafeInteger&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isPositive&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isNegative&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isNaN&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isInfiniteNumber&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isZero&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These help you express &lt;strong&gt;“valid numbers” more precisely&lt;/strong&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  🌟 More features
&lt;/h2&gt;

&lt;p&gt;There are more utilities available —&lt;br&gt;
feel free to explore the docs:&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://is-kit-docs.vercel.app/en" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;is-kit-docs.vercel.app&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;






&lt;h2&gt;
  
  
  🎯 Summary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;is-kit&lt;/code&gt; started as a small, composable type guard library.&lt;/p&gt;

&lt;p&gt;Over time, it has evolved into:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;a practical toolkit for handling &lt;code&gt;unknown&lt;/code&gt; in application code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Key improvements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More expressive object schemas (&lt;code&gt;optionalKey&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Better key-based narrowing&lt;/li&gt;
&lt;li&gt;Fail-fast assertions&lt;/li&gt;
&lt;li&gt;Collection support (&lt;code&gt;Set&lt;/code&gt;, &lt;code&gt;Map&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Rich numeric guards&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal of &lt;code&gt;is-kit&lt;/code&gt; is simple:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Make type-safe code feel natural to write&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you try it and have ideas or feedback, feel free to share!&lt;/p&gt;

&lt;p&gt;See you in the next post 👋&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/is-kit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>opensource</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>is-kit vs Zod: A Practical Comparison from 3 Perspectives</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Wed, 22 Apr 2026 12:32:02 +0000</pubDate>
      <link>https://dev.to/nyaomaru/is-kit-vs-zod-a-practical-comparison-from-3-perspectives-21eh</link>
      <guid>https://dev.to/nyaomaru/is-kit-vs-zod-a-practical-comparison-from-3-perspectives-21eh</guid>
      <description>&lt;p&gt;Hi everyone!&lt;/p&gt;

&lt;p&gt;I’m a frontend engineer who sometimes wonders:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Am I using AI… or is AI using me?”&lt;/strong&gt;&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.amazonaws.com%2Fuploads%2Farticles%2Fhsmsau4fc655s5n4hrpj.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.amazonaws.com%2Fuploads%2Farticles%2Fhsmsau4fc655s5n4hrpj.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When writing runtime validation in TypeScript,&lt;br&gt;&lt;br&gt;
&lt;a href="https://zod.dev" rel="noopener noreferrer"&gt;&lt;code&gt;Zod&lt;/code&gt;&lt;/a&gt; is usually the first choice.&lt;/p&gt;

&lt;p&gt;There are other options like &lt;code&gt;Valibot&lt;/code&gt; and &lt;code&gt;ArkType&lt;/code&gt;,&lt;/p&gt;

&lt;p&gt;but today I want to look at something a bit different:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;
        nyaomaru
      &lt;/a&gt; / &lt;a href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;
        is-kit
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Lightweight, zero-dependency toolkit for building `isFoo` style type guards in TypeScript. Runtime-safe 🛡️, composable 🧩, and ergonomic ✨. npm -&amp;gt; https://www.npmjs.com/package/is-kit
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;is-kit&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;
  &lt;a rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/nyaomaru/is-kit/main/docs/public/iskit_image.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnyaomaru%2Fis-kit%2Fmain%2Fdocs%2Fpublic%2Fiskit_image.png" width="600" alt="is-kit logo"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://www.npmjs.com/package/is-kit" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/cb829c46f855a6f184f255391ea5fe58bb6ed17112e34d4856acd61dc63179c2/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f762f69732d6b69742e737667" alt="npm version"&gt;
  &lt;/a&gt;
  &lt;a href="https://jsr.io/@nyaomaru/is-kit" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/b6648f6bf32eb9dff2136ce8538ccb4cc6af537c1127292b5fb0de416f7a69b4/68747470733a2f2f696d672e736869656c64732e696f2f6a73722f762f406e79616f6d6172752f69732d6b6974" alt="JSR"&gt;
  &lt;/a&gt;
  &lt;a href="https://www.npmjs.com/package/is-kit" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/9b30eeaac46ebced99b5e0dc290fd045bc34c738c66b5d95c4b6ee8fed358b34/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f64742f69732d6b69742e737667" alt="npm downloads"&gt;
  &lt;/a&gt;
  &lt;a href="https://github.com/nyaomaru/is-kit/blob/main/LICENSE" rel="noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/631cdf8c56ad84dd8657fe3371b93015b5474c517422f968ff4ec944bb28bf2f/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f6c2f69732d6b69742e7376673f73616e6974697a653d74727565" alt="License"&gt;
  &lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;&lt;code&gt;is-kit&lt;/code&gt; is a lightweight, zero-dependency toolkit for building reusable TypeScript &lt;strong&gt;type guards&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It helps you write small &lt;code&gt;isFoo&lt;/code&gt; functions, compose them into &lt;strong&gt;richer runtime checks&lt;/strong&gt;, and keep &lt;strong&gt;TypeScript narrowing&lt;/strong&gt; natural inside regular control flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runtime-safe&lt;/strong&gt; 🛡️, &lt;strong&gt;composable&lt;/strong&gt; 🧩, and &lt;strong&gt;ergonomic&lt;/strong&gt; ✨ without asking you to adopt a heavy schema workflow.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build and reuse &lt;strong&gt;typed guards&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compose guards&lt;/strong&gt; with &lt;code&gt;and&lt;/code&gt;, &lt;code&gt;or&lt;/code&gt;, &lt;code&gt;not&lt;/code&gt;, &lt;code&gt;oneOf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate object&lt;/strong&gt; shapes and collections&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parse or assert&lt;/strong&gt; &lt;code&gt;unknown&lt;/code&gt; values without a large schema framework&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://is-kit-docs.vercel.app/" rel="nofollow noopener noreferrer"&gt;📚 Documentation Site&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Best for &lt;strong&gt;app-internal narrowing, filtering, and reusable guards&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;🤔 Why use &lt;code&gt;is-kit&lt;/code&gt;?&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;Tired of rewriting the same &lt;code&gt;isFoo&lt;/code&gt; checks again and again?&lt;/p&gt;

&lt;p&gt;&lt;code&gt;is-kit&lt;/code&gt; is a good fit when you want to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;write reusable &lt;code&gt;isX&lt;/code&gt;&lt;/strong&gt; functions instead of one-off inline checks&lt;/li&gt;
&lt;li&gt;keep runtime validation &lt;strong&gt;lightweight and dependency-free&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;narrow values directly&lt;/strong&gt; in &lt;code&gt;if&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, and other TypeScript control flow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;compose validation logic&lt;/strong&gt;…&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;At first glance, &lt;code&gt;is-kit&lt;/code&gt; and &lt;code&gt;Zod&lt;/code&gt; look similar.&lt;br&gt;&lt;br&gt;
But their &lt;strong&gt;design philosophy is very different&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;is-kit&lt;/code&gt; → a toolkit to &lt;strong&gt;compose type guards&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Zod&lt;/code&gt; → a library to &lt;strong&gt;define schemas and validate data&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So instead of asking &lt;em&gt;“which is better?”&lt;/em&gt;,&lt;br&gt;&lt;br&gt;
let’s compare them from a practical angle:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;When is each one easier to write?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We’ll look at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code size&lt;/li&gt;
&lt;li&gt;Readability&lt;/li&gt;
&lt;li&gt;How they handle types&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Same Example
&lt;/h2&gt;

&lt;p&gt;Let’s validate a simple &lt;code&gt;User&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  🔧 is-kit
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;and&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isInteger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;oneOfValues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;optionalKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;predicateToRefine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-kit&lt;/span&gt;&lt;span class="dl"&gt;"&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;isPositiveInt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;isInteger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;predicateToRefine&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&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;v&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;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&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;isUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;struct&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;isPositiveInt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;oneOfValues&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&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;member&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;nickname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;optionalKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;),&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;result&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="nx"&gt;role&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;h3&gt;
  
  
  💎 Zod
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&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;UserSchema&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;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;positive&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;name&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="na"&gt;role&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;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&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;member&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;nickname&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;optional&lt;/span&gt;&lt;span class="p"&gt;(),&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;result&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="nx"&gt;role&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;You can already see the difference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;is-kit&lt;/code&gt; → compose small guards (function-based)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Zod&lt;/code&gt; → define a schema (declarative)&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  1. Code Size
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Zod&lt;/code&gt; often looks shorter.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because you can stack constraints in one place:&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;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="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&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;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;positive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works very well for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forms&lt;/li&gt;
&lt;li&gt;API validation&lt;/li&gt;
&lt;li&gt;Input boundaries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the other hand, &lt;code&gt;is-kit&lt;/code&gt; shines when you want to reuse guards.&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isUser&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// already narrowed&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;users&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isUser&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can directly pass guards into control flow.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;Zod&lt;/code&gt;, you usually go through &lt;code&gt;safeParse&lt;/code&gt;,&lt;br&gt;
so it feels more like &lt;strong&gt;“calling validation”&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;👉 Summary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Want &lt;strong&gt;centralized validation rules&lt;/strong&gt; → &lt;code&gt;Zod&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Want &lt;strong&gt;reusable guards in logic&lt;/strong&gt; → &lt;code&gt;is-kit&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  2. Readability
&lt;/h2&gt;

&lt;p&gt;This depends on what “readable” means to you.&lt;/p&gt;
&lt;h3&gt;
  
  
  💎 Zod
&lt;/h3&gt;

&lt;p&gt;All rules are in one place:&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;UserSchema&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="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;You can instantly see:&lt;/p&gt;

&lt;p&gt;👉 “What shape is valid?”&lt;/p&gt;

&lt;p&gt;Great for boundaries.&lt;/p&gt;

&lt;h3&gt;
  
  
  🔧 is-kit
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;is-kit&lt;/code&gt; feels like normal TypeScript control flow.&lt;/p&gt;

&lt;p&gt;It also lets you compose guards step by 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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hasKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;narrowKeyTo&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-kit&lt;/span&gt;&lt;span class="dl"&gt;"&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;hasRole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hasKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;hasRole&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="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="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;;&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;byRole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;narrowKeyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;"&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;isAdmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;byRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;isAdmin&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="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="nx"&gt;role&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="nx"&gt;name&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;You can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Narrow the whole shape (&lt;code&gt;isUser&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Focus on a specific key (&lt;code&gt;role&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Narrow further (&lt;code&gt;admin&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This fits naturally with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Receive unknown → narrow step by step”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;👉 Summary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Want &lt;strong&gt;a clear spec of valid data&lt;/strong&gt; → &lt;code&gt;Zod&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Want &lt;strong&gt;natural control flow narrowing&lt;/strong&gt; → &lt;code&gt;is-kit&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  3. How Types Are Handled
&lt;/h2&gt;

&lt;p&gt;This is where the biggest difference appears.&lt;/p&gt;

&lt;h3&gt;
  
  
  💎 Zod → Schema → Type
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&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;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;UserSchema&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;ul&gt;
&lt;li&gt;Define schema&lt;/li&gt;
&lt;li&gt;Generate type&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Best when you want:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Validation and types in one place”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  🔧 is-kit → Guard → Narrowing
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;is-kit&lt;/code&gt; focuses on control flow:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&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 type is narrowed automatically.&lt;/p&gt;

&lt;p&gt;You can also extract the type:&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;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GuardedOf&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;is-kit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&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;GuardedOf&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;isUser&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;But the main strength is not type generation.&lt;/p&gt;

&lt;p&gt;👉 It’s this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TypeScript understands your logic as you narrow values&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Core Difference
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Zod&lt;/code&gt; → strong at schema → type&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;is-kit&lt;/code&gt; → strong at guard → control flow narrowing&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Perspective&lt;/th&gt;
&lt;th&gt;Zod 💎&lt;/th&gt;
&lt;th&gt;is-kit 🔧&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Code size&lt;/td&gt;
&lt;td&gt;Great for schema&lt;/td&gt;
&lt;td&gt;Great for reuse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Readability&lt;/td&gt;
&lt;td&gt;Clear validation rules&lt;/td&gt;
&lt;td&gt;Natural control flow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type handling&lt;/td&gt;
&lt;td&gt;Schema-driven types&lt;/td&gt;
&lt;td&gt;Control flow narrowing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  When to Use Each
&lt;/h2&gt;

&lt;p&gt;My approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;Zod&lt;/code&gt; for external inputs

&lt;ul&gt;
&lt;li&gt;API&lt;/li&gt;
&lt;li&gt;forms&lt;/li&gt;
&lt;li&gt;environment variables&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;Use &lt;code&gt;is-kit&lt;/code&gt; for internal logic

&lt;ul&gt;
&lt;li&gt;condition branches&lt;/li&gt;
&lt;li&gt;filtering&lt;/li&gt;
&lt;li&gt;composing guards&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;




&lt;h2&gt;
  
  
  They Are Not Competitors
&lt;/h2&gt;

&lt;p&gt;These tools are not direct competitors.&lt;/p&gt;

&lt;p&gt;They work well together.&lt;/p&gt;

&lt;p&gt;👉 Clean separation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Zod&lt;/code&gt; → validate at boundaries&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;is-kit&lt;/code&gt; → refine inside your app&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;is-kit&lt;/code&gt; is not a lightweight version of &lt;code&gt;Zod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It focuses on a different idea:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Making type guards feel natural in TypeScript&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And &lt;code&gt;Zod&lt;/code&gt; is still extremely strong:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Schema-first validation with type safety&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The real question is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Which one is better?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“Where do you enforce type safety in your design?”&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Try it if you're interested:&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.amazonaws.com%2Fuploads%2Farticles%2F3iya7n6y3u85noozv5bp.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.amazonaws.com%2Fuploads%2Farticles%2F3iya7n6y3u85noozv5bp.png" alt=" " width="800" height="572"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/is-kit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>opensource</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Building a Browser Game with React: What Doesn’t work well (Run Away From Work)</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Wed, 08 Apr 2026 14:30:28 +0000</pubDate>
      <link>https://dev.to/nyaomaru/building-a-browser-game-with-react-what-doesnt-work-well-run-away-from-work-1khf</link>
      <guid>https://dev.to/nyaomaru/building-a-browser-game-with-react-what-doesnt-work-well-run-away-from-work-1khf</guid>
      <description>&lt;p&gt;Hi!&lt;/p&gt;

&lt;p&gt;I'm &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;@nyaomaru&lt;/a&gt;, a frontend engineer who finally managed to make it to the first zombie in &lt;strong&gt;Resident Evil 9&lt;/strong&gt;. It was terrifying. 🧟🧑‍⚕️&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/BZFz58E4F70"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;A couple of articles ago, I introduced a browser game I built. Have you played it yet?&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://nyaomaru-portfolio.vercel.app/game" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fnyaomaru-portfolio.vercel.app%2Fassets%2Fnyaomaru_ogp.png" height="auto" class="m-0"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://nyaomaru-portfolio.vercel.app/game" rel="noopener noreferrer" class="c-link"&gt;
            Game - Nyaomaru
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Portfolio of Nyaomaru – A frontend engineer specializing in Vue, React, and TypeScript.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fnyaomaru-portfolio.vercel.app%2Ffavicon.svg"&gt;
          nyaomaru-portfolio.vercel.app
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;At first glance, it looks like a very simple game. But once I actually started building it, I ran into a surprising number of tricky problems.&lt;/p&gt;

&lt;p&gt;In this article, I’ll share the main things that tripped me up and how I dealt with them.&lt;/p&gt;

&lt;p&gt;Let’s get into it.&lt;/p&gt;




&lt;h2&gt;
  
  
  🍽️ Quick recap
&lt;/h2&gt;

&lt;p&gt;I built a browser game with:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Remix&lt;/code&gt; × &lt;code&gt;React&lt;/code&gt; × &lt;code&gt;TypeScript&lt;/code&gt; × &lt;code&gt;FSD&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If you want the full background, check out my previous two articles:&lt;/p&gt;


&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol" class="crayons-story__hidden-navigation-link"&gt;I Built a “Run Away From Work” Browser Game with React and TypeScript&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/nyaomaru" class="crayons-avatar  crayons-avatar--l  "&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1436158%2Fa78bc4ed-7796-4760-baa5-06283c2404d6.png" alt="nyaomaru profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/nyaomaru" class="crayons-story__secondary fw-medium m:hidden"&gt;
              nyaomaru
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                nyaomaru
                
              
              &lt;div id="story-author-preview-content-3327084" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/nyaomaru" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1436158%2Fa78bc4ed-7796-4760-baa5-06283c2404d6.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;nyaomaru&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Mar 18&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol" id="article-link-3327084"&gt;
          I Built a “Run Away From Work” Browser Game with React and TypeScript
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/gamedev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;gamedev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/react"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;react&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/typescript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;typescript&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/raised-hands-74b2099fd66a39f2d7eed9305ee0f4553df0eb7b4f11b01b6b1b499973048fe5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;28&lt;span class="hidden s:inline"&gt; reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              7&lt;span class="hidden s:inline"&gt; comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            5 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;



&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/nyaomaru/run-away-from-work-stopped-using-react-for-the-game-loop-3e0k" class="crayons-story__hidden-navigation-link"&gt;Run Away From Work — Stopped Using React for the Game Loop&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/nyaomaru" class="crayons-avatar  crayons-avatar--l  "&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1436158%2Fa78bc4ed-7796-4760-baa5-06283c2404d6.png" alt="nyaomaru profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/nyaomaru" class="crayons-story__secondary fw-medium m:hidden"&gt;
              nyaomaru
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                nyaomaru
                
              
              &lt;div id="story-author-preview-content-3395758" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/nyaomaru" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1436158%2Fa78bc4ed-7796-4760-baa5-06283c2404d6.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;nyaomaru&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/nyaomaru/run-away-from-work-stopped-using-react-for-the-game-loop-3e0k" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Mar 25&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/nyaomaru/run-away-from-work-stopped-using-react-for-the-game-loop-3e0k" id="article-link-3395758"&gt;
          Run Away From Work — Stopped Using React for the Game Loop
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/showdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;showdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/gamedev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;gamedev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/react"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;react&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/typescript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;typescript&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/nyaomaru/run-away-from-work-stopped-using-react-for-the-game-loop-3e0k" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/raised-hands-74b2099fd66a39f2d7eed9305ee0f4553df0eb7b4f11b01b6b1b499973048fe5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;20&lt;span class="hidden s:inline"&gt; reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/nyaomaru/run-away-from-work-stopped-using-react-for-the-game-loop-3e0k#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              6&lt;span class="hidden s:inline"&gt; comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;





&lt;h2&gt;
  
  
  ⛳ What I struggled with
&lt;/h2&gt;

&lt;p&gt;These were the biggest issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;extra whitespace when animating SVG-based sprites&lt;/li&gt;
&lt;li&gt;inconsistent frame behavior across different screen sizes and devices&lt;/li&gt;
&lt;li&gt;AI not generating the CSS animation I actually wanted&lt;/li&gt;
&lt;li&gt;collision detection that didn’t match the visuals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s go through them one by one.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. SVG sprite animation and the whitespace problem
&lt;/h2&gt;

&lt;p&gt;At first, I thought:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“They’re SVGs. Animation should be easy if I just swap them nicely.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It was not easy at all.&lt;/p&gt;

&lt;p&gt;The biggest issue was this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Even when two SVGs looked like they were the same size, their positions shifted during animation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;frame 1 and frame 2 of the running animation had slightly different visual centers&lt;/li&gt;
&lt;li&gt;the boss’s idle state and attack state didn’t line up at the bottom&lt;/li&gt;
&lt;li&gt;even with the same &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt;, the actual rendered position didn’t match perfectly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So from React’s point of view, I was “just swapping images of the same size.”&lt;/p&gt;

&lt;p&gt;But in reality, differences in the SVG &lt;code&gt;viewBox&lt;/code&gt; and internal whitespace made them visibly jump.&lt;/p&gt;

&lt;p&gt;In the end, instead of trying to force the SVG contents to align perfectly, I treated the outer container as fixed and only swapped the sprite inside it.&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;img&lt;/span&gt;
  &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;playerSpriteRef&lt;/span&gt;&lt;span class="si"&gt;}&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;PLAYER_RUN_SPRITES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"player"&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playerSprite&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;draggable&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;aria-hidden&lt;/span&gt;
&lt;span class="p"&gt;/&amp;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 css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.playerSprite&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&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;height&lt;/span&gt;&lt;span class="p"&gt;:&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;object-fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;contain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;object-position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt; &lt;span class="nb"&gt;bottom&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 key was to align everything to the &lt;strong&gt;bottom&lt;/strong&gt;.&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.amazonaws.com%2Fuploads%2Farticles%2Fc15zre6ediuyxcf539t5.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.amazonaws.com%2Fuploads%2Farticles%2Fc15zre6ediuyxcf539t5.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That alone greatly reduced the weird “bouncing” feeling when frames switched.&lt;/p&gt;

&lt;p&gt;🔑 The takeaway here was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat SVGs not as visuals, but as boxes that include invisible padding.&lt;/strong&gt; 📦&lt;/p&gt;

&lt;p&gt;If you underestimate that, it will absolutely come back to bite you later.&lt;/p&gt;


&lt;h3&gt;
  
  
  2. DOM movement behaving differently depending on display size and device
&lt;/h3&gt;

&lt;p&gt;This was the next problem.&lt;/p&gt;

&lt;p&gt;At first, I used &lt;code&gt;requestAnimationFrame&lt;/code&gt; and moved obstacles left a little bit on every frame.&lt;/p&gt;

&lt;p&gt;That sounds reasonable, but it caused the &lt;strong&gt;game speed to vary depending on the device&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;requestAnimationFrame&lt;/code&gt; does not guarantee perfectly consistent timing.&lt;/p&gt;

&lt;p&gt;That led to problems like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the game felt smooth on desktop&lt;/li&gt;
&lt;li&gt;obstacles looked slower on mobile when FPS dropped&lt;/li&gt;
&lt;li&gt;differences in screen size and rendering load changed the perceived difficulty&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I left that alone, the exact same code would create effectively different games on different devices.&lt;/p&gt;

&lt;p&gt;So instead of moving things by a fixed amount per frame, I switched to time-based movement.&lt;/p&gt;

&lt;p&gt;The important thing here was using &lt;code&gt;deltaTime&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;deltaTime&lt;/code&gt; represents how many milliseconds passed between the previous frame and the current one.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;at 60fps, one frame is about &lt;code&gt;16.6ms&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;at 30fps, one frame is about &lt;code&gt;33.3ms&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So when FPS drops, each frame covers more time.&lt;/p&gt;

&lt;p&gt;If you move an obstacle by the same number of pixels every frame, then lower-FPS devices will make the obstacle move more slowly overall.&lt;/p&gt;

&lt;p&gt;But with &lt;code&gt;deltaTime&lt;/code&gt;, you can adjust movement based on elapsed time.&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;frameDistancePx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obstacleSpeedPxPerSec&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;deltaTimeMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;frameDistancePx&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This made it much easier to keep movement consistent on a per-second basis, even when FPS fluctuated.&lt;/p&gt;

&lt;p&gt;That said, &lt;code&gt;deltaTime&lt;/code&gt; alone was not enough to fully close the gap.&lt;/p&gt;

&lt;p&gt;So after switching to time-based movement, I also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;used different speed constants for mobile and desktop&lt;/li&gt;
&lt;li&gt;applied an additional pace scale only on large desktop screens&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That let me preserve the original feel on desktop without making mobile gameplay too slow or too heavy.&lt;/p&gt;

&lt;p&gt;🔑 The takeaway here was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Game logic should be designed around time, not frames.&lt;/strong&gt; 🕛&lt;/p&gt;

&lt;p&gt;Browser games may look simple, but if you ignore device differences, the overall quality drops fast.&lt;/p&gt;


&lt;h3&gt;
  
  
  3. AI couldn’t generate the CSS animation I actually wanted
&lt;/h3&gt;

&lt;p&gt;This one was subtle, but painful.&lt;/p&gt;

&lt;p&gt;For some effects, like the clear animation, I needed something with these characteristics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the image changes midway&lt;/li&gt;
&lt;li&gt;the starting position is only known at runtime&lt;/li&gt;
&lt;li&gt;the duration also changes depending on the situation&lt;/li&gt;
&lt;li&gt;but the overall animation shape should stay fixed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This kind of thing did not go well with &lt;code&gt;codex&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It really struggled with animations that included dynamic values like runtime position and duration.&lt;/p&gt;

&lt;p&gt;So I ended up handling this part myself.&lt;/p&gt;

&lt;p&gt;At first, I tried doing everything inline.&lt;/p&gt;

&lt;p&gt;But once I started pushing even &lt;code&gt;@keyframes&lt;/code&gt;-related concerns into the component, the responsibilities between rendering and visual behavior got messy very quickly.&lt;/p&gt;

&lt;p&gt;So I split it like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fixed animation definitions live in CSS Modules&lt;/li&gt;
&lt;li&gt;only dynamic values like position and duration are passed via CSS variables
&lt;/li&gt;
&lt;/ul&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;div&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flyoutMotion&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;--flyout-origin-x&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;specialFlyoutOrigin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--flyout-origin-y&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;specialFlyoutOrigin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--flyout-duration-ms&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;specialFlyoutDurationMs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;ms`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;CSSProperties&lt;/span&gt;
  &lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;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 css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.flyoutMotion&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;left&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;--flyout-origin-x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;top&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;--flyout-origin-y&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;special-flyout-motion&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;--flyout-duration-ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cubic-bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;forwards&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;From there, I fine-tuned the easing with &lt;code&gt;cubic-bezier&lt;/code&gt; and adjusted the actual motion in the browser, kind of like shaping keyframes in After Effects.&lt;/p&gt;

&lt;p&gt;🔑 The takeaway here was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep fixed animation logic in CSS, and pass only dynamic values through CSS variables.&lt;/strong&gt; 🫱&lt;/p&gt;

&lt;p&gt;That separation made the animation code much easier to reason about and tweak later.&lt;/p&gt;


&lt;h3&gt;
  
  
  4. Collision detection didn’t match the visuals
&lt;/h3&gt;

&lt;p&gt;And finally, collision detection.&lt;/p&gt;

&lt;p&gt;This is always painful in browser games, and this project was no exception.&lt;/p&gt;

&lt;p&gt;The main issue was this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The visual shape and the actual collision area did not match.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I naïvely used &lt;code&gt;getBoundingClientRect()&lt;/code&gt; against everything, I ran into problems like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;collisions triggering even when it didn’t look like the player touched anything&lt;/li&gt;
&lt;li&gt;obvious hits sometimes slipping through&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reasons were mainly these two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the visible SVG shape didn’t match its rectangular bounds&lt;/li&gt;
&lt;li&gt;the “visually correct” size and the “feels fair in gameplay” size were different for each obstacle&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, desk-type obstacles had a large visual box, so using the raw rectangle made collisions feel unfair.&lt;/p&gt;

&lt;p&gt;Here’s what the desk obstacle looked like:&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.amazonaws.com%2Fuploads%2Farticles%2Ftzovv7ny344fdvveldk5.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.amazonaws.com%2Fuploads%2Farticles%2Ftzovv7ny344fdvveldk5.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So I separated visual size from hitbox size.&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;obstacleElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hitboxScale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;resolvedHitboxScale&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hitboxScale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hitboxScale&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&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;obstacleHitbox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getScaledHitboxFromRectLike&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obstacleBox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hitboxScale&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;isCollision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;playerRect&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isPlayerOverlappingHitbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerRect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;obstacleHitbox&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That way, the obstacle could stay visually large and impactful, while the collision box could be slightly reduced to feel fair.&lt;/p&gt;

&lt;p&gt;This also made balancing much easier, because I could tweak hitbox scale per obstacle.&lt;/p&gt;

&lt;p&gt;🔑 The takeaway here was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visuals and collision should be designed separately.&lt;/strong&gt; 🛠️&lt;/p&gt;

&lt;p&gt;This was probably the part that made me feel the most like, “Okay, now I’m actually building a game.”&lt;/p&gt;


&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The game looks simple, but the implementation involved a lot of careful decisions.&lt;/p&gt;

&lt;p&gt;The biggest lessons were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;treat SVGs as boxes that include whitespace&lt;/li&gt;
&lt;li&gt;design movement based on time, not frames&lt;/li&gt;
&lt;li&gt;separate CSS and JS responsibilities clearly&lt;/li&gt;
&lt;li&gt;separate visuals and hit detection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Getting these details right makes a huge difference in how polished the game feels.&lt;/p&gt;

&lt;p&gt;So if you’re thinking about building a browser game, I’d really recommend paying attention to these parts from the beginning. 🚀&lt;/p&gt;

&lt;p&gt;Also, the repository is public, so feel free to take a look:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;
        nyaomaru
      &lt;/a&gt; / &lt;a href="https://github.com/nyaomaru/nyaomaru-portfolio" rel="noopener noreferrer"&gt;
        nyaomaru-portfolio
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      "Run Away From Work" Game is here → https://nyaomaru-portfolio.vercel.app/game
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Nyaomaru Portfolio&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
    &lt;a rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/nyaomaru/nyaomaru-portfolio/main/public/assets/nyaomaru_game_thumbnail.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnyaomaru%2Fnyaomaru-portfolio%2Fmain%2Fpublic%2Fassets%2Fnyaomaru_game_thumbnail.png" width="600px" alt="nyaomaru run game thumbnail"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;"Run Away From Work"&lt;/p&gt;

&lt;p&gt;You can play here: &lt;a href="https://nyaomaru-portfolio.vercel.app/game" rel="nofollow noopener noreferrer"&gt;https://nyaomaru-portfolio.vercel.app/game&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Remix&lt;/code&gt; + &lt;code&gt;React&lt;/code&gt; + &lt;code&gt;TypeScript&lt;/code&gt; portfolio built with Feature-Sliced Design.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://feature-sliced.github.io/documentation/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/7957ab11c8d162af98add2597ff870e7c08725f501980553160128b7938f9a6b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f466561747572652d2d536c696365642d44657369676e3f7374796c653d666f722d7468652d626164676526636f6c6f723d463246324632266c6162656c436f6c6f723d323632323234266c6f676f57696474683d3130266c6f676f3d646174613a696d6167652f706e673b6261736536342c6956424f5277304b47676f414141414e53556845556741414142514141414161434159414141433367337839414141414358424957584d4141414c46414141437851474a316e2f764141414141584e535230494172733463365141414141526e51553142414143786a7776385951554141414249535552425648674237644b784351416744455452307732637773306379733263776845554262736767696b437556656b4448775351466c596f37512b384b6e6d74486446574d646b32636c35775373627847535a7738646d387058395a4855544d4255674755324637313841414141415355564f524b35435949493d" alt="shields-fsd-domain"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;🚀 Highlights&lt;/h2&gt;
&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jump Game (Main Feature)&lt;/strong&gt;: Side-scrolling jump game with obstacles, boss phases, clear sequences, and restart flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interactive Terminal&lt;/strong&gt;: Ask profile-related questions in a terminal-style UI backed by the &lt;code&gt;/api/ask&lt;/code&gt; endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responsive UI&lt;/strong&gt;: Works across desktop and mobile layouts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FSD Architecture&lt;/strong&gt;: Organized by features/widgets/pages/shared layers.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;🎮 Main Feature: Game&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;
    &lt;a rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/nyaomaru/nyaomaru-portfolio/main/public/assets/gif/run_away_from_work.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnyaomaru%2Fnyaomaru-portfolio%2Fmain%2Fpublic%2Fassets%2Fgif%2Frun_away_from_work.gif" width="600px" alt="nyaomaru run game gif"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;The game is available at &lt;code&gt;/game&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Can you watch true ending? 🚀&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;🕹️ Controls&lt;/h3&gt;

&lt;/div&gt;

&lt;p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;br&gt;
&lt;thead&gt;
&lt;br&gt;
&lt;tr&gt;
&lt;br&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;br&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;br&gt;
&lt;/tr&gt;
&lt;br&gt;
&lt;/thead&gt;
&lt;br&gt;
&lt;tbody&gt;
&lt;br&gt;
&lt;tr&gt;
&lt;br&gt;
&lt;td&gt;Space / Click&lt;/td&gt;
&lt;br&gt;
&lt;td&gt;Jump&lt;/td&gt;
&lt;br&gt;
&lt;/tr&gt;
&lt;br&gt;
&lt;tr&gt;
&lt;br&gt;
&lt;td&gt;Tap&lt;/td&gt;
&lt;br&gt;
&lt;td&gt;Jump&lt;/td&gt;
&lt;br&gt;
&lt;/tr&gt;
&lt;br&gt;
&lt;tr&gt;
&lt;br&gt;
&lt;td&gt;Double Jump&lt;/td&gt;
&lt;br&gt;
&lt;td&gt;Jump again while in the air&lt;/td&gt;
&lt;br&gt;
&lt;/tr&gt;
&lt;br&gt;
&lt;/tbody&gt;
&lt;br&gt;
&lt;/table&gt;&lt;/div&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;💻 Terminal&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;The terminal is available on the top page and is designed for profile Q&amp;amp;A.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;✨ What It Does&lt;/h3&gt;

&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;Sends user input to &lt;code&gt;/api/ask&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Returns an AI-generated answer based on profile context.&lt;/li&gt;
&lt;li&gt;Shows typing/waiting states in terminal history.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;
⚠️ Important&lt;/h3&gt;

&lt;/div&gt;

&lt;p&gt;&lt;code&gt;/api/ask&lt;/code&gt;…&lt;/p&gt;
&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/nyaomaru/nyaomaru-portfolio" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;





&lt;h2&gt;
  
  
  Bonus: Sound effects matter too
&lt;/h2&gt;

&lt;p&gt;One thing that turned out to be more important than I expected was sound. 🔊&lt;/p&gt;

&lt;p&gt;Audio files need to be lightweight. Even a small delay can hurt the feel of the game.&lt;/p&gt;

&lt;p&gt;I originally exported my sound effects from &lt;code&gt;GarageBand&lt;/code&gt; as &lt;code&gt;wav&lt;/code&gt; files, but the file sizes were too large and the game started to feel noticeably heavier.&lt;/p&gt;

&lt;p&gt;So I used &lt;code&gt;ffmpeg&lt;/code&gt; to convert them to &lt;code&gt;ogg&lt;/code&gt; and reduce the size.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; input.wav &lt;span class="nt"&gt;-ac&lt;/span&gt; 1 &lt;span class="nt"&gt;-ar&lt;/span&gt; 22050 &lt;span class="nt"&gt;-b&lt;/span&gt;:a 64k output.ogg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After converting them like this, things got much lighter.&lt;/p&gt;

&lt;p&gt;And just to be safe, I also recommend preparing an &lt;code&gt;mp3&lt;/code&gt; fallback.&lt;/p&gt;

&lt;p&gt;So the takeaway here was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small sound files matter. A lot. If you care about game feel, optimize audio too.&lt;/strong&gt; 👍&lt;/p&gt;

&lt;p&gt;Happy coding! 💻&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>typescript</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Run Away From Work — Stopped Using React for the Game Loop</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Wed, 25 Mar 2026 13:11:16 +0000</pubDate>
      <link>https://dev.to/nyaomaru/run-away-from-work-stopped-using-react-for-the-game-loop-3e0k</link>
      <guid>https://dev.to/nyaomaru/run-away-from-work-stopped-using-react-for-the-game-loop-3e0k</guid>
      <description>&lt;p&gt;&lt;a href="https://nyaomaru-portfolio.vercel.app/game" rel="noopener noreferrer"&gt;Play the game here&lt;/a&gt; 👈&lt;/p&gt;

&lt;p&gt;Hi there!&lt;/p&gt;

&lt;p&gt;I’m &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;@nyaomaru&lt;/a&gt;, a frontend engineer who got &lt;em&gt;Resident Evil 9&lt;/em&gt; from NVIDIA and is still too scared to make it to the first zombie. 🧟&lt;/p&gt;

&lt;p&gt;In my previous article, I introduced my small browser game, &lt;strong&gt;Run Away From Work&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;At first glance, it looks like a very simple game.&lt;br&gt;&lt;br&gt;
But while building it, I realized something important:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you design the game around &lt;code&gt;setState&lt;/code&gt; on every frame, it falls apart very quickly.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every frame becomes:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setState&lt;/code&gt; → re-render → diff → DOM update&lt;/p&gt;

&lt;p&gt;And if you try to do that at 60fps, it gets heavy fast.&lt;/p&gt;

&lt;p&gt;That does &lt;strong&gt;not&lt;/strong&gt; mean React is bad for games.&lt;/p&gt;

&lt;p&gt;It means this is the wrong place to use React’s rendering model.&lt;/p&gt;

&lt;p&gt;So in this article, I want to share how I changed the architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I used &lt;strong&gt;React for the structure&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;and &lt;strong&gt;direct DOM manipulation for high-frequency game updates&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  🐾 Quick recap
&lt;/h2&gt;

&lt;p&gt;This game was built with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Remix&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;React&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TypeScript&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Feature-Sliced Design&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want the overview of the project itself, here is the previous article:&lt;/p&gt;


&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol" class="crayons-story__hidden-navigation-link"&gt;I Built a “Run Away From Work” Browser Game with React and TypeScript&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/nyaomaru" class="crayons-avatar  crayons-avatar--l  "&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1436158%2Fa78bc4ed-7796-4760-baa5-06283c2404d6.png" alt="nyaomaru profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/nyaomaru" class="crayons-story__secondary fw-medium m:hidden"&gt;
              nyaomaru
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                nyaomaru
                
              
              &lt;div id="story-author-preview-content-3327084" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/nyaomaru" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1436158%2Fa78bc4ed-7796-4760-baa5-06283c2404d6.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;nyaomaru&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Mar 18&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol" id="article-link-3327084"&gt;
          I Built a “Run Away From Work” Browser Game with React and TypeScript
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/gamedev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;gamedev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/react"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;react&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/typescript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;typescript&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/raised-hands-74b2099fd66a39f2d7eed9305ee0f4553df0eb7b4f11b01b6b1b499973048fe5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;28&lt;span class="hidden s:inline"&gt; reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              Comments


              7&lt;span class="hidden s:inline"&gt; comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            5 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


&lt;p&gt;Even though this game is built in React, the most important part of the runtime behavior is &lt;strong&gt;not really handled by React&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because the mental model of React and the mental model of a game loop are very different.&lt;/p&gt;




&lt;h2&gt;
  
  
  ✏️ What this article focuses on
&lt;/h2&gt;

&lt;p&gt;Last time, I wrote more about &lt;em&gt;why&lt;/em&gt; I made the game.&lt;/p&gt;

&lt;p&gt;This time, I want to focus more on the &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;There are 3 points I especially want to show:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I used &lt;strong&gt;plain DOM rendering&lt;/strong&gt;, not &lt;code&gt;Canvas&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;I split the game logic into &lt;strong&gt;small hooks with clear responsibilities&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;I added several small &lt;strong&gt;optimizations&lt;/strong&gt; to make it feel smooth in the browser&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;All the visuals in the game are SVGs I made myself in Adobe Illustrator.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🥣 React as the shell, DOM as the runtime
&lt;/h2&gt;

&lt;p&gt;This game looks like a React app, but I did &lt;strong&gt;not&lt;/strong&gt; build it in a way that re-renders everything every frame.&lt;/p&gt;

&lt;p&gt;On the React side, I only render the stable structure of the scene.&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ROOT_CLASS_NAME&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;section&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gameRef&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;GAME_AREA_CLASS_NAME&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="nc"&gt;FishCounterOverlay&lt;/span&gt;
        &lt;span class="na"&gt;fishCount&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fishCount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;fishCounterIconSrc&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fishCounterDisplayIconSrc&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="nc"&gt;PlayerLayer&lt;/span&gt;
        &lt;span class="na"&gt;playerRef&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playerRef&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;playerSpriteRef&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;playerSpriteRef&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;playerStyle&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;playerStyle&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="nc"&gt;BossLayer&lt;/span&gt;
        &lt;span class="na"&gt;shouldRenderBoss&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;shouldRenderBoss&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;bossRef&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bossRef&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;bossSpriteRef&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bossSpriteRef&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;bossArmRef&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;refs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bossArmRef&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;bossStyle&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;bossStyle&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;bossArmStyle&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;bossArmStyle&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;section&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;section&lt;/span&gt;&lt;span class="p"&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;Here, React owns the static layers of the game:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;player&lt;/li&gt;
&lt;li&gt;boss&lt;/li&gt;
&lt;li&gt;UI overlay&lt;/li&gt;
&lt;li&gt;base scene structure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But moving entities like obstacles and fish are not stored in React state.&lt;/p&gt;

&lt;p&gt;Instead, I create DOM nodes directly when needed.&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;spawnObstacle&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;obs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;img&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;OBSTACLE_ICON_SOURCES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;iconIndex&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;absolute&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;initializeMovingEntityMotion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;getSpawnLeft&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="nx"&gt;gameRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;obstaclesRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obs&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 reason is simple:&lt;/p&gt;

&lt;p&gt;If I store side-scrolling obstacles in a React array state and update them frame by frame, &lt;strong&gt;the update cost becomes unnecessarily high.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;React is great at building and updating UI structure.&lt;/p&gt;

&lt;p&gt;But for game-like behavior where positions change dozens of times per second, directly manipulating the DOM through &lt;code&gt;refs&lt;/code&gt; felt much more natural.&lt;/p&gt;

&lt;p&gt;It also made the behavior easier to trace and debug.&lt;/p&gt;


&lt;h2&gt;
  
  
  🍴 React and games optimize for different things
&lt;/h2&gt;

&lt;p&gt;React is based on this model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;state changes&lt;/li&gt;
&lt;li&gt;re-render&lt;/li&gt;
&lt;li&gt;reconcile&lt;/li&gt;
&lt;li&gt;apply DOM updates&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Games are usually based on this model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;update positions every frame&lt;/li&gt;
&lt;li&gt;move objects every frame&lt;/li&gt;
&lt;li&gt;detect collisions every frame&lt;/li&gt;
&lt;li&gt;advance the simulation continuously&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are not the same thing.&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.amazonaws.com%2Fuploads%2Farticles%2Fp272o6he6bjjbrmdyuw7.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.amazonaws.com%2Fuploads%2Farticles%2Fp272o6he6bjjbrmdyuw7.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So in this project, I separated the responsibilities like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React for layout and UI&lt;/li&gt;
&lt;li&gt;imperative DOM updates for high-frequency motion&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That separation made the architecture much easier to work with.&lt;/p&gt;
&lt;h3&gt;
  
  
  What I stopped doing
&lt;/h3&gt;

&lt;p&gt;At first, I tried to manage everything with &lt;code&gt;useState&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That quickly led to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;re-rendering on every frame&lt;/li&gt;
&lt;li&gt;unnecessary diffing&lt;/li&gt;
&lt;li&gt;worse performance&lt;/li&gt;
&lt;li&gt;visibly choppy movement&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So halfway through development, I changed the design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I stopped trying to make the entire game fully declarative.&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  ✂️ Splitting game logic into hooks
&lt;/h2&gt;

&lt;p&gt;The hub of the whole game scene is &lt;code&gt;useJumpGameScene&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;gameOver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setGameOver&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;showBoss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setShowBoss&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;fishCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setFishCount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jump&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isOnGroundRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resetJumpState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;updateJumpFrame&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nf"&gt;useJump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerRef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;obstaclesRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spawnObstacle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spawnFish&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clearObstacles&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nf"&gt;useObstacles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gameRef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;resetPlayerSpriteState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;updatePlayerSpriteFrame&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nf"&gt;usePlayerSpriteAnimator&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;playerSpriteRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;gameOver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;isOnGroundRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;useGameLoop&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;gameOver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;bossRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;playerRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;obstaclesRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;gameRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;updateJumpFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;updatePlayerSpriteFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;setGameOver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;setShowBoss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;setGameOverIcon&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;Here is the rough responsibility split:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hook&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;useJump&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Jump behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;useObstacles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Spawn and remove obstacles / fish&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;usePlayerSpriteAnimator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Switch between running / jumping sprites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;useGameLoop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Per-frame progression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;useJumpGameScene&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Compose everything and expose values to the UI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key idea is this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use &lt;code&gt;useState&lt;/code&gt; only for values the UI actually needs to render&lt;/li&gt;
&lt;li&gt;use &lt;code&gt;useRef&lt;/code&gt; for fast-changing internal state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, the jump behavior looks like this:&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;posRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&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;jumpMotionPhaseRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;JumpMotionPhase&lt;/span&gt;&lt;span class="o"&gt;&amp;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;idle&lt;/span&gt;&lt;span class="dl"&gt;"&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;updateJumpFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;nowMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deltaTimeMs&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jumpMotionPhaseRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ascending&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;span class="nx"&gt;posRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&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="nx"&gt;jumpMaxHeightRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;posRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ASCENT_VELOCITY_PX_PER_MS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;deltaTimeMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;playerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;posRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jumpMotionPhaseRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;descending&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;span class="nx"&gt;posRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;posRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;descentVelocityPxPerMsRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;deltaTimeMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;playerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bottom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;posRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This means the jump position is updated &lt;strong&gt;without going through React rendering.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of storing the player’s vertical position in state and re-rendering on every frame, I keep it in a ref and write it directly to the DOM.&lt;/p&gt;

&lt;p&gt;That tradeoff worked much better for this kind of game behavior.&lt;/p&gt;


&lt;h2&gt;
  
  
  🧵 One game loop controls the clock
&lt;/h2&gt;

&lt;p&gt;The game progression runs on top of &lt;code&gt;requestAnimationFrame&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loop&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nowMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;timing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getFrameTiming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nowMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastFrameAtMsRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;lastFrameAtMsRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nowMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;updateJumpFrame&lt;/span&gt;&lt;span class="p"&gt;?.({&lt;/span&gt; &lt;span class="na"&gt;nowMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nowMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;deltaTimeMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deltaTimeMs&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;updatePlayerSpriteFrame&lt;/span&gt;&lt;span class="p"&gt;?.({&lt;/span&gt; &lt;span class="na"&gt;nowMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nowMs&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;fatalCollisionIcon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;updateObstaclesFrame&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;deltaTimeMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deltaTimeMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;obstacleSpeedPxPerSec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;obstaclesRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;playerRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;getGameWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;getPlayerRect&lt;/span&gt;&lt;span class="p"&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="nx"&gt;playerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;()&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="na"&gt;getGameRect&lt;/span&gt;&lt;span class="p"&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="nx"&gt;gameRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;()&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="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;fatalCollisionIcon&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="nf"&gt;triggerFault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fatalCollisionIcon&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;animationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;requestAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loop&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 loop handles things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;jump updates&lt;/li&gt;
&lt;li&gt;sprite animation updates&lt;/li&gt;
&lt;li&gt;obstacle movement&lt;/li&gt;
&lt;li&gt;collision checks&lt;/li&gt;
&lt;li&gt;boss appearance&lt;/li&gt;
&lt;li&gt;clear / fail transitions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;useGameLoop&lt;/code&gt; acts as the central clock of the game, while the other hooks behave like specialized modules plugged into that clock.&lt;/p&gt;

&lt;p&gt;That structure helped me keep the logic separated without scattering time-related behavior everywhere.&lt;/p&gt;


&lt;h2&gt;
  
  
  🕶 Small optimizations mattered a lot
&lt;/h2&gt;

&lt;p&gt;A lot of the smoothness came from tiny DOM-side optimizations.&lt;/p&gt;
&lt;h3&gt;
  
  
  Moving elements with transform
&lt;/h3&gt;

&lt;p&gt;For movement, I avoided updating &lt;code&gt;left&lt;/code&gt; every frame.&lt;/p&gt;

&lt;p&gt;Instead, I used &lt;code&gt;transform: translate3d(...)&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;initializeMovingEntityMotion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;spawnLeftPx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;spawnLeftPx&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spawnLeftPx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;spawnLeftPx&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="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;translateXPx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`translate3d(0px, 0px, 0px)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;willChange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transform&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;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;advanceMovingEntityMotion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;frameDistancePx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextTranslateXPx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;translateXPx&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;frameDistancePx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;translateXPx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nextTranslateXPx&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="nx"&gt;element&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`translate3d(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nextTranslateXPx&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px, 0px, 0px)`&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 helps avoid unnecessary layout work and gives the browser a stronger hint that the element is going to move.&lt;/p&gt;
&lt;h3&gt;
  
  
  Avoiding unnecessary sprite swaps
&lt;/h3&gt;

&lt;p&gt;I also avoided changing the player sprite image more than necessary.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;preload sprite assets in advance&lt;/li&gt;
&lt;li&gt;skip updates if the current sprite is already correct&lt;/li&gt;
&lt;li&gt;briefly keep the same sprite after landing to make the animation feel better
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;currentPlayerSpriteRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;spritePath&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nf"&gt;isSameSpriteSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerSpriteRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spritePath&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="k"&gt;return&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preloadedPlayerSprites&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spritePath&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;applySprite&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&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 kind of thing is small, but it affects the feeling of responsiveness more than I expected.&lt;/p&gt;
&lt;h3&gt;
  
  
  Only calculating when needed
&lt;/h3&gt;

&lt;p&gt;Collision handling also avoids doing full &lt;code&gt;getBoundingClientRect()&lt;/code&gt; calls every single time.&lt;/p&gt;

&lt;p&gt;I cache width, height, and bottom values in &lt;code&gt;dataset&lt;/code&gt; when entities are created, then reconstruct their rectangle when possible.&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;obstacleBox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;resolvedGameRect&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="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&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="nf"&gt;getObstacleRectFromCachedLayout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currentLeftPx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolvedGameRect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;
      &lt;span class="nx"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingClientRect&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;And I do not even start collision checks until the obstacle is close enough to the player.&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentLeftPx&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;gameWidth&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;OBSTACLE_PLAYER_PROXIMITY_CHECK_PX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;resolvedPlayerBox&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nf"&gt;getPlayerRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;resolvedGameRect&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nf"&gt;getGameRect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// collision logic starts here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That idea was very effective.&lt;/p&gt;

&lt;p&gt;There is no reason to do precise collision work for an obstacle that is still far away on the right side of the screen.&lt;/p&gt;

&lt;p&gt;That kind of selective work made the movement noticeably smoother.&lt;/p&gt;


&lt;h2&gt;
  
  
  🎯 Final thoughts
&lt;/h2&gt;

&lt;p&gt;The main lessons from this implementation were:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use React for layout and UI&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;refs&lt;/code&gt; and direct DOM manipulation for high-frequency updates&lt;/li&gt;
&lt;li&gt;Split the logic into &lt;code&gt;hooks&lt;/code&gt; with focused responsibilities&lt;/li&gt;
&lt;li&gt;Stack small optimizations like &lt;code&gt;transforms&lt;/code&gt;, &lt;code&gt;cached rects&lt;/code&gt;, &lt;code&gt;proximity-based collision checks&lt;/code&gt;, and &lt;code&gt;sprite preloading&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the conclusion is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;React is bad for browser games&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is closer to this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;React is not a great place to run your core game loop.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For this project, the best balance was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;declarative structure&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;imperative runtime behavior&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I build a different kind of game in the future, I might choose &lt;code&gt;Canvas&lt;/code&gt; or &lt;code&gt;WebGL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But for a small game embedded naturally inside a portfolio site, I found that &lt;strong&gt;a DOM-based approach worked surprisingly well.&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Bonus
&lt;/h2&gt;

&lt;p&gt;If you liked the game, I would love it if you starred the repository ⭐:&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;
        nyaomaru
      &lt;/a&gt; / &lt;a href="https://github.com/nyaomaru/nyaomaru-portfolio" rel="noopener noreferrer"&gt;
        nyaomaru-portfolio
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      "Run Away From Work" Game is here → https://nyaomaru-portfolio.vercel.app/game
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Nyaomaru Portfolio&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
    &lt;a rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/nyaomaru/nyaomaru-portfolio/main/public/assets/nyaomaru_game_thumbnail.png"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnyaomaru%2Fnyaomaru-portfolio%2Fmain%2Fpublic%2Fassets%2Fnyaomaru_game_thumbnail.png" width="600px" alt="nyaomaru run game thumbnail"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;"Run Away From Work"&lt;/p&gt;

&lt;p&gt;You can play here: &lt;a href="https://nyaomaru-portfolio.vercel.app/game" rel="nofollow noopener noreferrer"&gt;https://nyaomaru-portfolio.vercel.app/game&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Remix&lt;/code&gt; + &lt;code&gt;React&lt;/code&gt; + &lt;code&gt;TypeScript&lt;/code&gt; portfolio built with Feature-Sliced Design.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://feature-sliced.github.io/documentation/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/7957ab11c8d162af98add2597ff870e7c08725f501980553160128b7938f9a6b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f466561747572652d2d536c696365642d44657369676e3f7374796c653d666f722d7468652d626164676526636f6c6f723d463246324632266c6162656c436f6c6f723d323632323234266c6f676f57696474683d3130266c6f676f3d646174613a696d6167652f706e673b6261736536342c6956424f5277304b47676f414141414e53556845556741414142514141414161434159414141433367337839414141414358424957584d4141414c46414141437851474a316e2f764141414141584e535230494172733463365141414141526e51553142414143786a7776385951554141414249535552425648674237644b784351416744455452307732637773306379733263776845554262736767696b437556656b4448775351466c596f37512b384b6e6d74486446574d646b32636c35775373627847535a7738646d387058395a4855544d4255674755324637313841414141415355564f524b35435949493d" alt="shields-fsd-domain"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;🚀 Highlights&lt;/h2&gt;
&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jump Game (Main Feature)&lt;/strong&gt;: Side-scrolling jump game with obstacles, boss phases, clear sequences, and restart flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interactive Terminal&lt;/strong&gt;: Ask profile-related questions in a terminal-style UI backed by the &lt;code&gt;/api/ask&lt;/code&gt; endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responsive UI&lt;/strong&gt;: Works across desktop and mobile layouts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FSD Architecture&lt;/strong&gt;: Organized by features/widgets/pages/shared layers.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;🎮 Main Feature: Game&lt;/h2&gt;
&lt;/div&gt;

&lt;p&gt;
    &lt;a rel="noopener noreferrer nofollow" href="https://raw.githubusercontent.com/nyaomaru/nyaomaru-portfolio/main/public/assets/gif/run_away_from_work.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnyaomaru%2Fnyaomaru-portfolio%2Fmain%2Fpublic%2Fassets%2Fgif%2Frun_away_from_work.gif" width="600px" alt="nyaomaru run game gif"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;The game is available at &lt;code&gt;/game&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Can you watch true ending? 🚀&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;🕹️ Controls&lt;/h3&gt;

&lt;/div&gt;

&lt;p&gt;&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;br&gt;
&lt;thead&gt;
&lt;br&gt;
&lt;tr&gt;
&lt;br&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;br&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;br&gt;
&lt;/tr&gt;
&lt;br&gt;
&lt;/thead&gt;
&lt;br&gt;
&lt;tbody&gt;
&lt;br&gt;
&lt;tr&gt;
&lt;br&gt;
&lt;td&gt;Space / Click&lt;/td&gt;
&lt;br&gt;
&lt;td&gt;Jump&lt;/td&gt;
&lt;br&gt;
&lt;/tr&gt;
&lt;br&gt;
&lt;tr&gt;
&lt;br&gt;
&lt;td&gt;Tap&lt;/td&gt;
&lt;br&gt;
&lt;td&gt;Jump&lt;/td&gt;
&lt;br&gt;
&lt;/tr&gt;
&lt;br&gt;
&lt;tr&gt;
&lt;br&gt;
&lt;td&gt;Double Jump&lt;/td&gt;
&lt;br&gt;
&lt;td&gt;Jump again while in the air&lt;/td&gt;
&lt;br&gt;
&lt;/tr&gt;
&lt;br&gt;
&lt;/tbody&gt;
&lt;br&gt;
&lt;/table&gt;&lt;/div&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;💻 Terminal&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;The terminal is available on the top page and is designed for profile Q&amp;amp;A.&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;✨ What It Does&lt;/h3&gt;

&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;Sends user input to &lt;code&gt;/api/ask&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Returns an AI-generated answer based on profile context.&lt;/li&gt;
&lt;li&gt;Shows typing/waiting states in terminal history.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;
⚠️ Important&lt;/h3&gt;

&lt;/div&gt;

&lt;p&gt;&lt;code&gt;/api/ask&lt;/code&gt;…&lt;/p&gt;
&lt;/div&gt;


&lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/nyaomaru/nyaomaru-portfolio" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;


&lt;p&gt;You can also play it on Itch:&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://nyaomaru.itch.io/run-away-from-work" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;nyaomaru.itch.io&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>showdev</category>
      <category>gamedev</category>
      <category>react</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I Built a “Run Away From Work” Browser Game with React and TypeScript</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Wed, 18 Mar 2026 12:14:00 +0000</pubDate>
      <link>https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol</link>
      <guid>https://dev.to/nyaomaru/i-built-a-run-away-from-work-browser-game-with-react-and-typescript-44ol</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.amazonaws.com%2Fuploads%2Farticles%2F2k03xsh3hb98i11p6621.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.amazonaws.com%2Fuploads%2Farticles%2F2k03xsh3hb98i11p6621.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click Game Button 👇&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nyaomaru-portfolio.vercel.app/game" rel="noopener noreferrer"&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.amazonaws.com%2Fuploads%2Farticles%2Fy8hodn5dx2dlrtolo8bd.png" alt=" " width="200" height="71"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Hi there!&lt;/p&gt;

&lt;p&gt;I’m &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;@nyaomaru&lt;/a&gt;, a frontend engineer who got a cold after getting way too excited about spring and spending too much time in the sun. ☀️&lt;/p&gt;

&lt;p&gt;Thanks to AI, we can get work done faster than ever and handle a huge number of tasks in parallel.&lt;/p&gt;

&lt;p&gt;But at the same time… dealing with more and more work can get exhausting, right? 😿&lt;/p&gt;

&lt;p&gt;So I made a small &lt;strong&gt;browser game about running away from work&lt;/strong&gt; as a little break.&lt;/p&gt;

&lt;p&gt;Here’s what it looks like 👇&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.amazonaws.com%2Fuploads%2Farticles%2F9hvrz7vom3793fuemh8q.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.amazonaws.com%2Fuploads%2Farticles%2F9hvrz7vom3793fuemh8q.gif" alt=" " width="760" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://nyaomaru-portfolio.vercel.app/game" rel="noopener noreferrer"&gt;&lt;strong&gt;Play here&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It runs in the browser, so you can play it on both desktop and mobile.&lt;br&gt;&lt;br&gt;
It takes about &lt;strong&gt;1 minute&lt;/strong&gt; to play.&lt;/p&gt;

&lt;p&gt;Hope it gives you a small break 🙏&lt;/p&gt;


&lt;h2&gt;
  
  
  🎮 How to play
&lt;/h2&gt;

&lt;p&gt;The controls are simple.&lt;/p&gt;

&lt;p&gt;On desktop, press &lt;strong&gt;Space&lt;/strong&gt; or &lt;strong&gt;Click&lt;/strong&gt; to jump.&lt;br&gt;&lt;br&gt;
On mobile, just &lt;strong&gt;Tap&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You can even &lt;strong&gt;double jump&lt;/strong&gt;, so try to dodge everything nicely.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Space / Click&lt;/td&gt;
&lt;td&gt;Jump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tap&lt;/td&gt;
&lt;td&gt;Jump&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Double Jump&lt;/td&gt;
&lt;td&gt;Jump once more in the air&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;🐟 If you collect fish, something good might happen... maybe!!!???&lt;/p&gt;


&lt;h2&gt;
  
  
  🤔 Why I made this
&lt;/h2&gt;

&lt;p&gt;There were two main reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I wanted to study &lt;strong&gt;UI/UX&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;I wanted to see how far I could go using the technologies I’m already good at&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’m planning to get more serious about game development with &lt;code&gt;Unity&lt;/code&gt; in the future, but before that, I wanted to try making a browser game with my current stack: &lt;strong&gt;&lt;code&gt;React&lt;/code&gt; × &lt;code&gt;TypeScript&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Also, do you know Google’s 404 &lt;a href="https://trex-runner.com/" rel="noopener noreferrer"&gt;Dinosaur T-Rex Game&lt;/a&gt;?&lt;/p&gt;

&lt;p&gt;I’ve always liked how simple and fun it is.&lt;/p&gt;

&lt;p&gt;That made me want to build my own &lt;strong&gt;fun little runner game&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That’s basically it.&lt;/p&gt;


&lt;h2&gt;
  
  
  ⚙️ How I built it
&lt;/h2&gt;

&lt;p&gt;I had previously built my portfolio with &lt;code&gt;Remix&lt;/code&gt; × &lt;code&gt;FSD&lt;/code&gt;, so this game was made as an extension of that idea.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/nyaomaru/building-a-portfolio-site-with-fsd-langchain-remix-ai-c9h"&gt;https://dev.to/nyaomaru/building-a-portfolio-site-with-fsd-langchain-remix-ai-c9h&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the stack is basically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Remix&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;React&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TypeScript&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FSD&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why this structure?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Games tend to have &lt;strong&gt;a lot of state&lt;/strong&gt;, so I wanted to test how far I could manage things with &lt;code&gt;hooks&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;I wanted the codebase to stay &lt;strong&gt;type-safe&lt;/strong&gt; and maintainable&lt;/li&gt;
&lt;li&gt;I wanted to test the &lt;strong&gt;scalability of FSD&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;I wanted to include it in my portfolio&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’ll probably write another article later about the detailed implementation, but these were my main impressions after building it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;FSD&lt;/code&gt; helped me clearly separate UI and model responsibilities

&lt;ul&gt;
&lt;li&gt;That made it easier to add new features without affecting existing ones too much&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;I used &lt;code&gt;is-kit&lt;/code&gt; to build user-defined type guards in a type-safe way

&lt;ul&gt;
&lt;li&gt;Adding tests made the code more robust&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Extracting magic numbers into constants made debugging much easier

&lt;ul&gt;
&lt;li&gt;Separating &lt;code&gt;config&lt;/code&gt; and &lt;code&gt;constants&lt;/code&gt; helped me tune game-related values much more comfortably&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the way, &lt;code&gt;is-kit&lt;/code&gt; is a small utility library for building type guards more easily. Feel free to check it out 😸&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/is-kit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/nyaomaru/building-type-guards-like-lego-blocks-making-reusable-logic-with-is-kit-48e4"&gt;https://dev.to/nyaomaru/building-type-guards-like-lego-blocks-making-reusable-logic-with-is-kit-48e4&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  🤖 What it was like building a game with AI
&lt;/h2&gt;

&lt;p&gt;In my daily work I usually use &lt;strong&gt;Claude Code&lt;/strong&gt;, but for this experiment I decided to try building the game with &lt;strong&gt;&lt;code&gt;codex&lt;/code&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;While working on a browser game, I got the impression that this kind of UI is still pretty tricky for AI.&lt;/p&gt;
&lt;h3&gt;
  
  
  What was difficult 😅
&lt;/h3&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When I asked it to create CSS animations, the result was often &lt;strong&gt;completely off&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;It couldn’t really think through things like &lt;strong&gt;hit detection&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Sometimes the visual motion created through DOM manipulation did not match what I actually wanted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I had to move forward while &lt;strong&gt;debugging and adjusting things manually&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In many cases, I could fix things by giving more specific instructions.&lt;/p&gt;

&lt;p&gt;But for &lt;strong&gt;CSS animation&lt;/strong&gt; and &lt;strong&gt;collision-related behavior&lt;/strong&gt;, I often had to tweak things myself while watching how the game actually moved.&lt;/p&gt;

&lt;p&gt;Because of that, my impression was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;it’s still difficult to leave UI/UX entirely to generative AI&lt;/strong&gt;, especially when the interface is unusual.&lt;/p&gt;

&lt;p&gt;Of course, for something like a standard CRUD UI, AI can already do a decent job because there are many common patterns. In real-world work, I usually don’t struggle that much. And if there’s already a mockup in Figma, the output can be quite accurate.&lt;/p&gt;

&lt;p&gt;But for &lt;strong&gt;games&lt;/strong&gt; or &lt;strong&gt;unfamiliar UI/UX&lt;/strong&gt;, human adjustment is still very necessary.&lt;/p&gt;

&lt;p&gt;That said, AI is evolving at absurd speed — faster than bamboo shoots grow.&lt;/p&gt;

&lt;p&gt;So who knows what it’ll look like soon.&lt;/p&gt;


&lt;h3&gt;
  
  
  What worked well 🙌
&lt;/h3&gt;

&lt;p&gt;Because this was a more unusual implementation, I tried to help &lt;code&gt;codex&lt;/code&gt; learn from previous mistakes ahead of time.&lt;/p&gt;

&lt;p&gt;To do that, I prepared a &lt;strong&gt;&lt;code&gt;learn&lt;/code&gt; skill&lt;/strong&gt;, along with &lt;code&gt;AGENTS.md&lt;/code&gt; and &lt;code&gt;LEARNED_INDEX.md&lt;/code&gt; so it could keep referring back to what had already gone wrong and how it had been fixed.&lt;/p&gt;

&lt;p&gt;During development, whenever an instruction didn’t work well, I extracted the lesson and saved it into files. Then I made &lt;code&gt;AGENTS.md&lt;/code&gt; load those references.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LEARNED_INDEX.md&lt;/code&gt; worked as a summary of those learned notes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;.codex/
  skills/
    learn/
      learned/
        xxx.md       &lt;span class="c"&gt;# learned notes are stored here&lt;/span&gt;
        yyy.md
        zzz.md
    LEARNED_INDEX.md &lt;span class="c"&gt;# index for referencing files under learned&lt;/span&gt;
    SKILL.md         &lt;span class="c"&gt;# a skill for summarizing what went wrong in the session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This structure turned out to be really helpful for preserving instructions that normally wouldn’t appear in everyday development.&lt;/p&gt;

&lt;p&gt;For example, things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“Use the boss idle bob phase relative to its spawn timing”&lt;/li&gt;
&lt;li&gt;“Make the ground charge duration explicit so the attack timing becomes stable”&lt;/li&gt;
&lt;li&gt;“Control the clear animation in phases to avoid timing conflicts”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are small details, but they matter a lot, and I wanted them to stay consistent.&lt;/p&gt;

&lt;p&gt;By repeating this process, I was able to reduce unnecessary correction instructions over time.&lt;/p&gt;

&lt;p&gt;With this kind of setup, AI becomes much easier to work with even for personal projects with very specific requirements, which felt pretty nice.&lt;/p&gt;

&lt;p&gt;Also, the &lt;code&gt;learn&lt;/code&gt; skill was inspired by the &lt;strong&gt;learn commands&lt;/strong&gt; from the &lt;code&gt;everything-claude-code&lt;/code&gt; repository. Thx! 🙏&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/affaan-m/everything-claude-code/blob/main/commands/learn.md" rel="noopener noreferrer"&gt;https://github.com/affaan-m/everything-claude-code/blob/main/commands/learn.md&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 Conclusion
&lt;/h2&gt;

&lt;p&gt;This is a game where nyaomaru runs away from work on your behalf.&lt;/p&gt;

&lt;p&gt;What happens in the end?&lt;br&gt;
You’ll have to play it and find out.&lt;/p&gt;

&lt;p&gt;There are two endings.&lt;/p&gt;

&lt;p&gt;So go try it and see what kind of ending you get 😹&lt;/p&gt;

&lt;p&gt;Repository&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Game&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/nyaomaru-portfolio" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/nyaomaru-portfolio&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;is-kit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/is-kit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>webdev</category>
      <category>react</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Running a SPA inside ChatGPT using MCP Apps (Step-by-Step Guide)</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Mon, 02 Feb 2026 17:32:58 +0000</pubDate>
      <link>https://dev.to/nyaomaru/running-a-spa-inside-chatgpt-using-mcp-apps-step-by-step-guide-o52</link>
      <guid>https://dev.to/nyaomaru/running-a-spa-inside-chatgpt-using-mcp-apps-step-by-step-guide-o52</guid>
      <description>&lt;p&gt;Hi there!&lt;/p&gt;

&lt;p&gt;I’m &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;@nyaomaru&lt;/a&gt;, a frontend engineer who still uses a hot water bottle in 2026.&lt;/p&gt;

&lt;p&gt;A new era has arrived — you can now display and interact with your own web app directly inside the ChatGPT chat interface.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/" rel="noopener noreferrer"&gt;https://blog.modelcontextprotocol.io/posts/2026-01-26-mcp-apps/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you watch the video in the announcement, you’ll see what this looks like. A new common standard called &lt;strong&gt;MCP Apps&lt;/strong&gt; has been released, allowing interactive UIs to run inside ChatGPT and Claude 🎉&lt;/p&gt;

&lt;p&gt;In simple terms:&lt;br&gt;
You can now run your own web app inside a chat, using an iframe.&lt;/p&gt;

&lt;p&gt;As a developer, that’s something I definitely wanted to try myself.&lt;/p&gt;

&lt;p&gt;In this article, I’ll walk through how to display your app inside &lt;strong&gt;ChatGPT&lt;/strong&gt; step by step.&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.amazonaws.com%2Fuploads%2Farticles%2Fxi0mrnk5gjkpg9ebgv5f.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.amazonaws.com%2Fuploads%2Farticles%2Fxi0mrnk5gjkpg9ebgv5f.gif" alt=" " width="760" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let’s dive in.&lt;/p&gt;
&lt;h2&gt;
  
  
  What are MCP Apps?
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;MCP Apps provide a standardized way to deliver interactive UIs from MCP servers. Your UI renders inline in the conversation, in context, in any compliant host.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;MCP Apps are a standard that allows AI hosts to display and interact with external web UIs.&lt;/p&gt;

&lt;p&gt;Here’s the overall architecture:&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.amazonaws.com%2Fuploads%2Farticles%2Fp1yh4h3sn8dsrbpt4vvr.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.amazonaws.com%2Fuploads%2Farticles%2Fp1yh4h3sn8dsrbpt4vvr.png" alt=" " width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In MCP Apps, &lt;strong&gt;your UI does not talk directly to your MCP server.&lt;/strong&gt;&lt;br&gt;
Instead, &lt;strong&gt;ChatGPT (the host)&lt;/strong&gt; sits in the middle, fetching the UI and mediating all communication.&lt;/p&gt;

&lt;p&gt;Docs:&lt;br&gt;
&lt;a href="https://modelcontextprotocol.github.io/ext-apps/api/documents/Overview.html" rel="noopener noreferrer"&gt;https://modelcontextprotocol.github.io/ext-apps/api/documents/Overview.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So what does this enable?&lt;/p&gt;

&lt;p&gt;It allows ChatGPT or Claude to display a UI built with &lt;code&gt;ext-apps&lt;/code&gt;, via an MCP Server.&lt;/p&gt;

&lt;p&gt;⚠️ Important note:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Right now, the AI host must explicitly enable your app.&lt;br&gt;
This is not something users can trigger just by typing a prompt. The host needs to have your MCP server configured.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now, let’s go through how to build and publish this.&lt;/p&gt;
&lt;h2&gt;
  
  
  Publishing a UI using &lt;code&gt;ext-apps&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;First, we need a web app to display.&lt;/p&gt;

&lt;p&gt;If you don’t have one, the official examples are a great starting point:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/modelcontextprotocol/ext-apps/tree/main/examples" rel="noopener noreferrer"&gt;https://github.com/modelcontextprotocol/ext-apps/tree/main/examples&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/quickstart" rel="noopener noreferrer"&gt;https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/quickstart&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I added MCP support to my experimental project nyaomaru 3D. Here’s the PR diff:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/nyaomaru-3D/pull/11/changes" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/nyaomaru-3D/pull/11/changes&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This works fine in a monorepo too, but here I’ll assume the frontend and MCP server live in separate repositories.&lt;/p&gt;
&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;

&lt;p&gt;Here’s the SPA we’ll use:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/nyaomaru-3D" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/nyaomaru-3D&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Install &lt;code&gt;ext-apps&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.npmjs.com/package/@modelcontextprotocol/ext-apps" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@modelcontextprotocol/ext-apps&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @modelcontextprotocol/ext-apps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since my app uses &lt;code&gt;Vue&lt;/code&gt; + &lt;code&gt;Vite&lt;/code&gt;, I also added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add &lt;span class="nt"&gt;-D&lt;/span&gt; vite-plugin-singlefile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, create an HTML entry file for MCP (I called mine &lt;code&gt;mcp-app.html&lt;/code&gt;):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/nyaomaru-3D/blob/main/mcp-app.html" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/nyaomaru-3D/blob/main/mcp-app.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then create a bundle entry:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/nyaomaru-3D/blob/main/src/mcp-app.ts" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/nyaomaru-3D/blob/main/src/mcp-app.ts&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My UI component looks like this:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;App&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/ext-apps&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Nyaomaru MCP App&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.1.0&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;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;autoResize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;onMounted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;onBeforeUnmount&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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 key part is &lt;code&gt;app.connect()&lt;/code&gt; — this starts communication with the host (ChatGPT).&lt;br&gt;
If you forget this, your UI won’t work inside ChatGPT.&lt;/p&gt;
&lt;h3&gt;
  
  
  Build config
&lt;/h3&gt;

&lt;p&gt;I separated the MCP build output using a custom &lt;code&gt;Vite&lt;/code&gt; mode:&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="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;mode&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isMcpBuild&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;vue&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;isMcpBuild&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;viteSingleFile&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="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isMcpBuild&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist-mcp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;emptyOutDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;rollupOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fileURLToPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp-app.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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="p"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&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;span class="nl"&gt;"build:mcp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vue-tsc -b &amp;amp;&amp;amp; vite build --mode mcp"&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="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Deploy
&lt;/h3&gt;

&lt;p&gt;Deploy the MCP build separately (I used &lt;code&gt;Vercel&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build Command: &lt;code&gt;pnpm build:mcp&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Output Directory: &lt;code&gt;dist-mcp&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;https://&lt;span class="o"&gt;{&lt;/span&gt;project-name&lt;span class="o"&gt;}&lt;/span&gt;.vercel.app/mcp-app.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this page loads, your UI is ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the MCP Server
&lt;/h2&gt;

&lt;p&gt;Now we create the MCP server that tells ChatGPT how to load our UI.&lt;/p&gt;

&lt;p&gt;Here’s my example repo:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/nyaomaru-3D-mcp-ui" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/nyaomaru-3D-mcp-ui&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm add @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key parts:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;McpServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/sdk/server/mcp.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;registerAppResource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;registerAppTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;RESOURCE_MIME_TYPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/ext-apps/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nyaomaru-3d-ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.0.0&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;span class="nf"&gt;registerAppTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open-nyaomaru-3d-ui&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;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Open Nyaomaru 3D UI&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Render the Nyaomaru 3D MCP UI.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="na"&gt;_meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;ui&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;resourceUri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RESOURCE_URI&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="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async &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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;content&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Opening Nyaomaru 3D UI...&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;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="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;registerAppResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;RESOURCE_URI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;RESOURCE_URI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RESOURCE_MIME_TYPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&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;fetchUiHtml&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;contents&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="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RESOURCE_URI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RESOURCE_MIME_TYPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;html&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="p"&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;Then expose via HTTP:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StreamableHTTPServerTransport&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/sdk/server/streamableHttp.js&lt;/span&gt;&lt;span class="dl"&gt;'&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cors&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/mcp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StreamableHTTPServerTransport&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;sessionIdGenerator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;enableJsonResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&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;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&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;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;3001&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`MCP UI server listening on http://localhost:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/mcp`&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;h2&gt;
  
  
  Exposing the server with ngrok
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ngrok http 3001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the HTTPS URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuring ChatGPT
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open ChatGPT&lt;/li&gt;
&lt;li&gt;Settings → Apps → Advanced → Enable Developer Mode&lt;/li&gt;
&lt;li&gt;Create a new app&lt;/li&gt;
&lt;li&gt;Enter your ngrok URL (e.g. &lt;a href="https://xxx.ngrok-free.dev/mcp" rel="noopener noreferrer"&gt;https://xxx.ngrok-free.dev/mcp&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;No auth&lt;/li&gt;
&lt;li&gt;Save&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then enable the app in chat and ask ChatGPT to open it.&lt;/p&gt;

&lt;p&gt;If everything worked — congrats! Your SPA is now running inside ChatGPT 🎉&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;Seeing your own app running inside an AI chat interface feels like the future.&lt;/p&gt;

&lt;p&gt;We’re moving from “AI responds with text” to &lt;strong&gt;“AI hosts applications.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Right now, AI control over the iframe is limited, but this space is evolving fast.&lt;/p&gt;

&lt;p&gt;If you build products, this could soon become a new way users interact with your services.&lt;/p&gt;

&lt;p&gt;Definitely worth experimenting with now.&lt;/p&gt;

&lt;p&gt;If you’re experimenting with MCP Apps too, I’d love to see what you build 👀&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>vue</category>
      <category>chatgpt</category>
      <category>mcp</category>
    </item>
    <item>
      <title>2025: I Shipped 3 OSS Projects — “This Was Actually Fine”</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Wed, 31 Dec 2025 06:08:20 +0000</pubDate>
      <link>https://dev.to/nyaomaru/2025-i-shipped-3-oss-projects-this-was-actually-fine-3e0c</link>
      <guid>https://dev.to/nyaomaru/2025-i-shipped-3-oss-projects-this-was-actually-fine-3e0c</guid>
      <description>&lt;p&gt;Hi everyone!&lt;/p&gt;

&lt;p&gt;I’m a frontend engineer, &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;@nyaomaru&lt;/a&gt;.&lt;br&gt;&lt;br&gt;
Recently, for dieting purposes, I’ve been taking walks while carrying a backpack weighing over 10kg, kind of like Master Roshi’s (Kame-Sennin) training from Dragon Ball 🐢.&lt;/p&gt;

&lt;p&gt;Another year flew by before I noticed it.&lt;/p&gt;

&lt;p&gt;It’s already the end of the year.&lt;/p&gt;

&lt;p&gt;At the end of the year, we have cleaning, shopping, and &lt;strong&gt;reflection&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
In Japanese, December is called &lt;em&gt;“Shiwasu(師走)”&lt;/em&gt;, meaning “even teachers run because they’re so busy.”&lt;br&gt;&lt;br&gt;
As for me, I’ve been running around so much that I feel like a completely worn-out rag.&lt;/p&gt;

&lt;p&gt;Anyway, clean up this year’s mess within this year.&lt;br&gt;&lt;br&gt;
Let’s also organize our thoughts while we’re at it 🧹&lt;/p&gt;

&lt;p&gt;So, I’d like to look back on &lt;strong&gt;2025 from the perspective of OSS projects I released&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
Just to be clear: &lt;strong&gt;there are no viral success stories here.&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  🎯 OSS Projects I Released in 2025
&lt;/h2&gt;

&lt;p&gt;This year, I released the following three OSS projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;is-kit&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&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.amazonaws.com%2Fuploads%2Farticles%2Fiwzzlk83ksk5q960awbd.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.amazonaws.com%2Fuploads%2Farticles%2Fiwzzlk83ksk5q960awbd.png" alt=" " width="800" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/is-kit&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;changelog-bot&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&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.amazonaws.com%2Fuploads%2Farticles%2Fzyu78ox96p2r7j8tmtid.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.amazonaws.com%2Fuploads%2Farticles%2Fzyu78ox96p2r7j8tmtid.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/changelog-bot" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/changelog-bot&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;divider&lt;/code&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&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.amazonaws.com%2Fuploads%2Farticles%2F9gtiy7yzg2gohhoyayuy.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.amazonaws.com%2Fuploads%2Farticles%2F9gtiy7yzg2gohhoyayuy.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/divider" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/divider&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I continue maintaining and releasing updates for all of them.&lt;/p&gt;


&lt;h2&gt;
  
  
  🤔 Why Did I Build Them?
&lt;/h2&gt;

&lt;p&gt;Simply put: &lt;strong&gt;because I wanted them myself&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;is-kit&lt;/code&gt;: “There must be a cleaner way to write user-defined type guards…”&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;changelog-bot&lt;/code&gt;: “Writing &lt;code&gt;CHANGELOG.md&lt;/code&gt; every time is honestly annoying…”&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;divider&lt;/code&gt;: “I just want to split strings more cleanly…”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each project started from a small frustration.&lt;br&gt;&lt;br&gt;
I asked myself, &lt;em&gt;“How can I remove this discomfort?”&lt;/em&gt; and started building.&lt;/p&gt;

&lt;p&gt;Of course, it’s great when others use your OSS.&lt;br&gt;
But my main focus was always: &lt;strong&gt;can this solve my own problem properly?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let’s briefly look back at each project.&lt;/p&gt;


&lt;h2&gt;
  
  
  is-kit
&lt;/h2&gt;

&lt;p&gt;If you use TypeScript, you probably write user-defined type guards fairly often.&lt;/p&gt;

&lt;p&gt;For example:&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;type&lt;/span&gt; &lt;span class="nx"&gt;User&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;guest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;trial&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;A typical type guard might look like this:&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;isUser&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="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;User&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="k"&gt;typeof&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="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;unknown&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;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;guest&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;trial&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I kept thinking: &lt;em&gt;“Can’t this be simpler?”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That’s when the idea of &lt;strong&gt;LEGO blocks&lt;/strong&gt; came to mind.&lt;br&gt;
If we could compose small logical pieces, building complex type guards would be much easier.&lt;/p&gt;

&lt;p&gt;So I designed &lt;code&gt;is-kit&lt;/code&gt; around composable logic like &lt;code&gt;and&lt;/code&gt;, &lt;code&gt;or&lt;/code&gt;, and &lt;code&gt;not&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;is-kit&lt;/code&gt;, the same example becomes:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oneOfValues&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;is-kit&lt;/span&gt;&lt;span class="dl"&gt;'&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;isUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;struct&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;&amp;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;isString&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;oneOfValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&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="s1"&gt;guest&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="s1"&gt;trial&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;More declarative, more readable, and still type-safe — sounds nice, right?&lt;/p&gt;

&lt;p&gt;You can also compose guards further:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;narrowKeyTo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;predicateToRefine&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;is-kit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AdminUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Readonly&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;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&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;byRole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;narrowKeyTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;role&lt;/span&gt;&lt;span class="dl"&gt;'&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;isAdminUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;byRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&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;isAdultAdmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;isAdminUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;predicateToRefine&lt;/span&gt;&lt;span class="p"&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;AdminUser&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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;18&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;h3&gt;
  
  
  What Worked Well with is-kit
&lt;/h3&gt;

&lt;p&gt;A lot of people checked it out and gave it stars. Thank you so so so much 🙏🙏🙏&lt;/p&gt;

&lt;p&gt;I also discovered &lt;code&gt;tsd&lt;/code&gt;, a library for testing TypeScript type definitions, which was genuinely fun to work with.&lt;/p&gt;

&lt;p&gt;The API stayed fairly simple, and overall, I’m happy with how it turned out.&lt;br&gt;
There’s still room for improvement, so I’ll keep enhancing it.&lt;/p&gt;

&lt;p&gt;Planned improvements include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More primitive presets&lt;/li&gt;
&lt;li&gt;More combinators&lt;/li&gt;
&lt;li&gt;Improvements around &lt;code&gt;struct&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you like it, feel free to give it a ⭐️!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/is-kit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The goal was never magic — just composability and readability.&lt;/p&gt;


&lt;h2&gt;
  
  
  changelog-bot
&lt;/h2&gt;

&lt;p&gt;When you release an OSS project, you usually write release notes and update &lt;code&gt;CHANGELOG.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But… it’s kind of a hassle, right?&lt;/p&gt;

&lt;p&gt;At least, it was for me 🤮&lt;/p&gt;

&lt;p&gt;There are tools that generate changelogs from conventional commits, but I wondered:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Could AI classify changes based on content instead?”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That question led to &lt;code&gt;changelog-bot&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can use it via CLI with OpenAI or Anthropic API keys (AI is optional):&lt;/p&gt;

&lt;p&gt;It’s mainly designed to run in CI.&lt;br&gt;
Once a release is published, your &lt;code&gt;CHANGELOG.md&lt;/code&gt; can be updated automatically.&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update Changelog&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;changelog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
      &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nyaomaru/changelog-bot@v0&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;changelog-path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CHANGELOG.md&lt;/span&gt;
          &lt;span class="na"&gt;base-branch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
          &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openai&lt;/span&gt;
          &lt;span class="na"&gt;release-tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.release.tag_name }}&lt;/span&gt;
          &lt;span class="na"&gt;release-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.release.tag_name }}&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.OPENAI_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also run it locally via CLI if you want. Details are in the &lt;code&gt;README&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Worked Well with changelog-bot
&lt;/h3&gt;

&lt;p&gt;It completely removed the manual effort of maintaining &lt;code&gt;CHANGELOG.md&lt;/code&gt; for my projects.&lt;br&gt;
Releasing became much easier 🚀&lt;/p&gt;

&lt;p&gt;I personally enjoyed designing the preprocessing logic that extracts and scores features before passing data to the AI.&lt;/p&gt;

&lt;p&gt;That said, there’s still room for improvement, and contributions are welcome!&lt;/p&gt;

&lt;p&gt;One note though:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Writing changelogs became easier. &lt;strong&gt;But... life itself didn’t magically become easier&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That part is still under observation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/changelog-bot" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/changelog-bot&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  divider
&lt;/h2&gt;

&lt;p&gt;Sometimes you end up slicing strings over and over with &lt;code&gt;substring&lt;/code&gt;, and it gets messy.&lt;/p&gt;

&lt;p&gt;I wanted a cleaner way.&lt;/p&gt;

&lt;p&gt;So I built &lt;code&gt;divider&lt;/code&gt;, which lets you split strings in one shot using indices.&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;divider&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@nyaomaru/divider&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;divider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What I Learned from divider
&lt;/h3&gt;

&lt;p&gt;A great engineer contributed, I’m truly grateful 🙏&lt;/p&gt;

&lt;p&gt;More importantly, I learned the basics of OSS hygiene:&lt;br&gt;
&lt;code&gt;CODE_OF_CONDUCT.md&lt;/code&gt;, &lt;code&gt;CONTRIBUTING.md&lt;/code&gt;, &lt;code&gt;CHANGELOG.md&lt;/code&gt;, &lt;code&gt;DEVELOPER.md&lt;/code&gt;, etc.&lt;/p&gt;

&lt;p&gt;However, there was a clear downside.&lt;/p&gt;

&lt;p&gt;I couldn’t demonstrate a strong advantage over &lt;code&gt;string.split()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That’s reflected in the number of stars, and honestly, it was a design mistake.&lt;/p&gt;

&lt;p&gt;In short, this project was a &lt;strong&gt;“useful failure.”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Still, it was a valuable learning experience, and I’m glad I built it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/divider" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/divider&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  ✨ Goals for 2026
&lt;/h2&gt;

&lt;p&gt;In 2025, I:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Released OSS projects&lt;/li&gt;
&lt;li&gt;Started writing technical articles&lt;/li&gt;
&lt;li&gt;Moved to the Netherlands 🇳🇱&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It was a year full of new challenges.&lt;/p&gt;

&lt;p&gt;For 2026, I’m thinking about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Releasing OSS applications related to DSA&lt;/li&gt;
&lt;li&gt;Publishing a small game project&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But above all, my main goal is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don’t burn out. Keep going.&lt;/strong&gt; 🏃‍♂️&lt;/p&gt;

&lt;p&gt;That applies to OSS, systems, and life itself.&lt;/p&gt;

&lt;p&gt;Thank you for reading, and I hope you have a great year ahead.&lt;/p&gt;

&lt;p&gt;See you in 2026 🐈&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>typescript</category>
      <category>github</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Tried Reading React's Source Code and Flow Beat Me Up. So Let's Learn 🚀</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Sun, 14 Dec 2025 06:09:03 +0000</pubDate>
      <link>https://dev.to/nyaomaru/i-tried-reading-reacts-source-code-and-flow-beat-me-up-so-lets-learn-4jf0</link>
      <guid>https://dev.to/nyaomaru/i-tried-reading-reacts-source-code-and-flow-beat-me-up-so-lets-learn-4jf0</guid>
      <description>&lt;p&gt;Hi everyone!&lt;/p&gt;

&lt;p&gt;I'm &lt;strong&gt;&lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;@nyaomaru&lt;/a&gt;&lt;/strong&gt;, a frontend engineer who eats too much cheese 🧀 and is slowly gaining weight.&lt;/p&gt;

&lt;p&gt;Quick detour: this article says that to become a good engineer, you need a &lt;strong&gt;deep understanding of code&lt;/strong&gt;, and that &lt;strong&gt;reading existing code&lt;/strong&gt; is essential:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/thebitforge/10-developer-habits-that-separate-good-programmers-from-great-ones-293n"&gt;https://dev.to/thebitforge/10-developer-habits-that-separate-good-programmers-from-great-ones-293n&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I totally agree.&lt;/p&gt;

&lt;p&gt;So I started doing a &lt;strong&gt;deep reading of React's source code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And honestly?&lt;/p&gt;

&lt;p&gt;It’s been &lt;em&gt;surprisingly fun&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Every day I discover something new, like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“Oh, &lt;code&gt;memo&lt;/code&gt; bails out when there’s no &lt;code&gt;Fiber + props/state&lt;/code&gt; update!”&lt;/li&gt;
&lt;li&gt;“Wait, &lt;code&gt;&amp;lt;Activity&amp;gt;&lt;/code&gt; in &lt;code&gt;v19.2&lt;/code&gt; is wrapped with &lt;code&gt;Offscreen&lt;/code&gt; and switches behavior by mode?”&lt;/li&gt;
&lt;li&gt;“They use &lt;strong&gt;bit flags&lt;/strong&gt; instead of booleans… no wonder it’s fast even with complex state!”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have some free time during the holidays, I highly recommend trying it.&lt;br&gt;
How about &lt;strong&gt;“React deep reading without giving up”&lt;/strong&gt; for New Year’s Eve?&lt;br&gt;&lt;br&gt;
…just a thought.&lt;/p&gt;


&lt;h2&gt;
  
  
  But then… Flow happened 😇
&lt;/h2&gt;

&lt;p&gt;As I kept reading, I hit a wall.&lt;/p&gt;

&lt;p&gt;I’m used to reading &lt;strong&gt;TypeScript&lt;/strong&gt;, but React’s core is written in &lt;strong&gt;Flow&lt;/strong&gt;, and there are many places where Flow’s type system behaves differently.&lt;/p&gt;

&lt;p&gt;Honestly, I almost gave up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Wait… is this syntax? a type? a comment?&lt;br&gt;
&lt;strong&gt;…a spell?&lt;/strong&gt;”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My brain felt like it was being beaten up &lt;em&gt;physically&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So I decided to write this article with one goal:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Understand Flow’s quirks quickly, and lower the barrier to reading React’s source code.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;People who want to read React’s source code more deeply&lt;/li&gt;
&lt;li&gt;People who’ve heard of Flow, but never really understood it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Alright, let’s dive in!&lt;/p&gt;


&lt;h2&gt;
  
  
  🌊 What is Flow?
&lt;/h2&gt;

&lt;p&gt;First, let’s clarify what Flow actually is.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://flow.org/" rel="noopener noreferrer"&gt;https://flow.org/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flow is a static type checker for JavaScript.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At first glance it feels similar to TypeScript, but the philosophy is different:&lt;br&gt;
Flow is much closer to a &lt;strong&gt;pure type system&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Flow has been developed by &lt;strong&gt;Meta (formerly Facebook)&lt;/strong&gt; alongside React itself, so React’s core type definitions are deeply written in Flow.&lt;/p&gt;

&lt;p&gt;The key point is this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Flow adds type safety &lt;em&gt;on top of&lt;/em&gt; JavaScript.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
It does &lt;em&gt;not&lt;/em&gt; extend JavaScript with new language features like TypeScript does.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That difference matters a lot when you read React’s internals.&lt;/p&gt;


&lt;h2&gt;
  
  
  🧩 Why should we learn Flow?
&lt;/h2&gt;

&lt;p&gt;If you read React’s source code, you’ll constantly encounter Flow types in core concepts like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Suspense&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Fiber&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Lanes&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Offscreen&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Especially tricky concepts include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;variance&lt;/code&gt; (covariant / contravariant / invariant)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;maybe&lt;/code&gt; (&lt;code&gt;?T&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mixed&lt;/code&gt; / &lt;code&gt;empty&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;exact objects&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Many of these &lt;strong&gt;aren't the same meanings in TypeScript&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Without Flow knowledge, you’ll often stop and wonder:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Is this a type? syntax? or some utility?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That friction adds up and makes reading slower — and easier to give up.&lt;/p&gt;

&lt;p&gt;Once you understand Flow, &lt;strong&gt;React’s source code suddenly becomes much more readable&lt;/strong&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  ⚔️ Flow vs TypeScript (quick comparison)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Flow&lt;/th&gt;
&lt;th&gt;TypeScript&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary goal&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Static type checking&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Static types + language extensions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strictness&lt;/td&gt;
&lt;td&gt;Stricter / theory-oriented&lt;/td&gt;
&lt;td&gt;Practical / sometimes permissive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Notable features&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Exact Object&lt;/code&gt;, &lt;code&gt;variance&lt;/code&gt;, &lt;code&gt;opaque types&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rich unions, enums&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React core&lt;/td&gt;
&lt;td&gt;Written in Flow&lt;/td&gt;
&lt;td&gt;Apps mostly written in TypeScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning benefit&lt;/td&gt;
&lt;td&gt;Understand React internals&lt;/td&gt;
&lt;td&gt;Best for app development&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;👉 &lt;strong&gt;Flow is NOT “the origin of TypeScript.”&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
They evolved on &lt;strong&gt;different paths&lt;/strong&gt;, with different goals.&lt;/p&gt;


&lt;h2&gt;
  
  
  🌀 Flow Types We’ll Focus On
&lt;/h2&gt;

&lt;p&gt;We’ll skip the basics that feel similar to TypeScript, like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Primitive types&lt;/li&gt;
&lt;li&gt;Literal types&lt;/li&gt;
&lt;li&gt;Functions&lt;/li&gt;
&lt;li&gt;Utilities&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead, we’ll focus on Flow types that often confuse people when reading React:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mixed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;empty&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maybe&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;opaque&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;exact&lt;/code&gt; / &lt;code&gt;inexact objects&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(There are many more — check the &lt;a href="https://flow.org/" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; if you’re curious!)&lt;/p&gt;

&lt;p&gt;For variance, see this article:&lt;br&gt;
&lt;a href="https://dev.to/nyaomaru/understanding-variance-in-typescript-flow-covariant-contravariant-invariant-bivariant-4fbi"&gt;https://dev.to/nyaomaru/understanding-variance-in-typescript-flow-covariant-contravariant-invariant-bivariant-4fbi&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  🌪️ Understanding &lt;code&gt;mixed&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Let’s start with &lt;code&gt;mixed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In Flow, &lt;code&gt;mixed&lt;/code&gt; is very similar to TypeScript’s &lt;code&gt;unknown&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You’ll see it in React when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;handling errors&lt;/li&gt;
&lt;li&gt;dealing with callbacks&lt;/li&gt;
&lt;li&gt;accepting “anything, but safely”&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;mixed&lt;/code&gt; is the &lt;em&gt;safest&lt;/em&gt; “anything”
&lt;/h3&gt;

&lt;p&gt;From a type theory perspective, &lt;code&gt;mixed&lt;/code&gt; is a &lt;strong&gt;top type&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Any value can be here, but you’re not allowed to use it until you prove what it is.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Unlike TypeScript’s &lt;code&gt;any&lt;/code&gt;, which says “do whatever you want,”&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mixed&lt;/code&gt; says: &lt;strong&gt;“Show me the type first.”&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;numberOrString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mixed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// x + 1;   // ❌ error&lt;/span&gt;
  &lt;span class="c1"&gt;// x.name; // ❌ error&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;x&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="c1"&gt;// ✅ OK&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;You &lt;strong&gt;must narrow&lt;/strong&gt; before using it.&lt;/p&gt;

&lt;p&gt;This prevents bugs caused by casually handling unknown values.&lt;/p&gt;

&lt;p&gt;If you know &lt;code&gt;unknown&lt;/code&gt; in TypeScript, this should feel very familiar.&lt;/p&gt;




&lt;h2&gt;
  
  
  🕳️ Understanding empty
&lt;/h2&gt;

&lt;p&gt;Next up: &lt;code&gt;empty&lt;/code&gt;.&lt;br&gt;
(Don’t worry — not talking about &lt;strong&gt;my bank account&lt;/strong&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;empty&lt;/code&gt; represents the &lt;strong&gt;bottom type&lt;/strong&gt; in Flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;empty&lt;/code&gt; means “this value can never exist”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Literally:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“There is no possible value of this type.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not &lt;code&gt;number&lt;/code&gt;, not &lt;code&gt;string&lt;/code&gt;, not &lt;code&gt;null&lt;/code&gt; — nothing.&lt;/p&gt;

&lt;p&gt;This is very close to TypeScript’s &lt;code&gt;never&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;foo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;empty&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// type-checks, but can never be called&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function is &lt;em&gt;logically&lt;/em&gt; unreachable.&lt;/p&gt;

&lt;p&gt;Flow often uses &lt;code&gt;empty&lt;/code&gt; to represent &lt;strong&gt;impossible states.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is this useful?
&lt;/h3&gt;

&lt;p&gt;Example: unreachable branches.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ❌ unreachable → inferred as empty&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;Or functions that never return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;throwError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;empty&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&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;Or &lt;strong&gt;exhaustive checks&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;voice&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="s1"&gt;cat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;meow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return &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="nx"&gt;empty&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you forget a case, Flow will complain — at compile time.&lt;/p&gt;




&lt;h2&gt;
  
  
  🌫️ Understanding &lt;code&gt;maybe&lt;/code&gt; (&lt;code&gt;?T&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Flow’s &lt;code&gt;maybe&lt;/code&gt; type is written as &lt;code&gt;?T&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;T&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;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Much shorter, right?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;greet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="nx"&gt;string&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="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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hello, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hello!&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;Using &lt;code&gt;!= null&lt;/code&gt; is intentional here — it safely removes both &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  🕶️ Understanding &lt;code&gt;opaque&lt;/code&gt; types
&lt;/h2&gt;

&lt;p&gt;Now for one of Flow’s most stylish features: opaque types.&lt;/p&gt;

&lt;p&gt;Normally, if two types share the same structure, they’re interchangeable.&lt;/p&gt;

&lt;p&gt;Flow lets you say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“They look the same — but they are NOT the same.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  TypeScript alias example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PostId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kr"&gt;string&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;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aaa&lt;/span&gt;&lt;span class="dl"&gt;'&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;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PostId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// allowed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aliases are just aliases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flow opaque types
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;opaque&lt;/span&gt; &lt;span class="nx"&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;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;opaque&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PostId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locally they look similar — but export them, and things change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="nx"&gt;opaque&lt;/span&gt; &lt;span class="nx"&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;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Outside the module, &lt;code&gt;UserId&lt;/code&gt; becomes &lt;strong&gt;truly opaque&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You cannot forge it.&lt;/p&gt;

&lt;p&gt;This gives Flow &lt;strong&gt;true nominal typing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;TypeScript’s branded types are similar — but can be bypassed with casts.&lt;br&gt;
Flow’s opaque types &lt;strong&gt;cannot be forged outside the module&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That’s why they’re so powerful.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧩 Exact vs Inexact Objects
&lt;/h2&gt;

&lt;p&gt;This is not a personality test.&lt;/p&gt;

&lt;p&gt;Flow objects come in two flavors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exact (default)&lt;/strong&gt; — strict&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inexact (&lt;code&gt;...&lt;/code&gt;)&lt;/strong&gt; — permissive&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Inexact
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&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="kd"&gt;const&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;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nyaomaru&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// OK&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Extra properties are allowed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exact (default)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&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;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nyaomaru&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ❌ error&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even passing through variables won’t bypass this.&lt;/p&gt;

&lt;p&gt;This strictness prevents accidental object pollution — something TypeScript allows in some cases.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏁 Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Once you understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;mixed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;empty&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maybe&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;opaque&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;exact&lt;/code&gt; / &lt;code&gt;inexact&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;React’s source code stops looking like magic spells.&lt;/p&gt;

&lt;p&gt;You start thinking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Ah — that’s why they typed it this way.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So grab some noodles (or pasta 🍝), and try reading React’s source code again.&lt;/p&gt;

&lt;p&gt;Happy holidays, and happy hacking!&lt;/p&gt;




&lt;h2&gt;
  
  
  Bonus
&lt;/h2&gt;

&lt;p&gt;You can try to use flow in below playground 🚀&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/flow-playground" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/flow-playground&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Please check my OSS for building type guards easily: &lt;code&gt;is-kit&lt;/code&gt; 😸&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.amazonaws.com%2Fuploads%2Farticles%2Fzf2cy0u8e5d5y8r1a216.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.amazonaws.com%2Fuploads%2Farticles%2Fzf2cy0u8e5d5y8r1a216.png" alt=" " width="634" height="175"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/is-kit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>react</category>
      <category>typescript</category>
      <category>programming</category>
    </item>
    <item>
      <title>Generate CHANGELOG.md Automatically 🤖</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Sun, 30 Nov 2025 13:15:01 +0000</pubDate>
      <link>https://dev.to/nyaomaru/generate-changelogmd-automatically-26g1</link>
      <guid>https://dev.to/nyaomaru/generate-changelogmd-automatically-26g1</guid>
      <description>&lt;p&gt;Hey everyone! Happy winter is upcoming! ⛄️&lt;/p&gt;

&lt;p&gt;I’m &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;@nyaomaru&lt;/a&gt;, a frontend engineer.&lt;/p&gt;

&lt;p&gt;Today I’d like to introduce &lt;strong&gt;&lt;code&gt;changelog-bot&lt;/code&gt;&lt;/strong&gt;, a tool that automatically generates a polished &lt;code&gt;CHANGELOG.md&lt;/code&gt; from your release notes! 🚀&lt;/p&gt;




&lt;h2&gt;
  
  
  🧪 Background
&lt;/h2&gt;

&lt;p&gt;If you’re maintaining a project, you’ve probably used &lt;code&gt;CHANGELOG.md&lt;/code&gt; to keep track of release details.&lt;br&gt;
But let’s be honest—updating it manually with every version bump is &lt;em&gt;tedious&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Sure, you can automate parts of it, but most generators produce unstructured or messy output.&lt;br&gt;
That’s exactly the pain point that inspired &lt;strong&gt;&lt;code&gt;changelog-bot&lt;/code&gt;&lt;/strong&gt;!&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.amazonaws.com%2Fuploads%2Farticles%2Fudphqqcsl63ac3cid0yh.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.amazonaws.com%2Fuploads%2Farticles%2Fudphqqcsl63ac3cid0yh.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/changelog-bot" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/changelog-bot&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  🎁 Why changelog-bot?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🖋 &lt;strong&gt;Automates&lt;/strong&gt; the boring part — generating &lt;code&gt;CHANGELOG.md&lt;/code&gt;

&lt;ul&gt;
&lt;li&gt;Uses AI to structure and format the changelog with high accuracy&lt;/li&gt;
&lt;li&gt;Analyzes commit messages and PR titles to automatically categorize “fix”, “feat”, etc.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Works directly from release notes&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;No need for strict Conventional Commit rules&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Works even without AI&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Fallback logic builds changelogs accurately even without an API key&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  🔎 How to Use It
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Try it instantly (npx / pnpx)
&lt;/h3&gt;

&lt;p&gt;The easiest way to try it out is from the CLI using &lt;code&gt;npx&lt;/code&gt; or &lt;code&gt;pnpm dlx&lt;/code&gt;!&lt;/p&gt;
&lt;h4&gt;
  
  
  pnpm
&lt;/h4&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm dlx @nyaomaru/changelog-bot &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--release-tag&lt;/span&gt; v0.0.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--provider&lt;/span&gt; openai &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;💡 Tip: Remove --dry-run to actually create a PR with your updated CHANGELOG.md!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want to enable AI-assisted formatting, set your API key beforehand:&lt;/p&gt;

&lt;p&gt;For security reasons, &lt;code&gt;.env&lt;/code&gt; files are not automatically loaded.&lt;br&gt;
Please export your environment variables manually.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# For OpenAI users&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-xxxx   &lt;span class="c"&gt;# Use OpenAI as the provider&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gpt-4o-mini &lt;span class="c"&gt;# Optional: specify a model&lt;/span&gt;

&lt;span class="c"&gt;# For Anthropic users&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-ant-xxxx
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;claude-3-5-sonnet-20240620
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Setting up a local dev environment with mise
&lt;/h3&gt;

&lt;p&gt;If you’d like to explore the source or contribute, set up Node and pnpm with mise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mise &lt;span class="nb"&gt;install&lt;/span&gt;     &lt;span class="c"&gt;# Installs Node 22 / pnpm 10.12&lt;/span&gt;
mise dev_install &lt;span class="c"&gt;# Installs dependencies&lt;/span&gt;
mise build       &lt;span class="c"&gt;# Compiles TypeScript&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, export your environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;REPO_FULL_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_name/your_repository_name &lt;span class="c"&gt;# Target repository&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ghp_xxx                          &lt;span class="c"&gt;# GitHub token&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-xxx                         &lt;span class="c"&gt;# Optional: for AI formatting&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, just run the CLI — make sure to set your version properly!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mise start &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;--release-tag&lt;/span&gt; v0.0.1 &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;--provider&lt;/span&gt; openai &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡 Tip: Remove --dry-run to apply changes to your CHANGELOG.md via PR!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;If no API key is provided, it falls back to commit log analysis&lt;/li&gt;
&lt;li&gt;You can swap models by setting &lt;code&gt;OPENAI_MODEL&lt;/code&gt; or &lt;code&gt;ANTHROPIC_MODEL&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Automate with GitHub Actions
&lt;/h3&gt;

&lt;p&gt;Even easier—let GitHub Actions handle it.&lt;br&gt;
Just drop a workflow like this under &lt;code&gt;.github/workflows/&lt;/code&gt; and your changelog will grow automatically whenever a release tag is pushed:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Auto Changelog&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v*'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;changelog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nyaomaru/changelog-bot/.github/workflows/changelog.yaml@v0&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;changelog_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CHANGELOG.md&lt;/span&gt;
      &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openai&lt;/span&gt;
      &lt;span class="na"&gt;release_tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.ref_name }}&lt;/span&gt;
      &lt;span class="na"&gt;dry_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;false'&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;REPO_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
      &lt;span class="na"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.OPENAI_API_KEY }}&lt;/span&gt;
      &lt;span class="na"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ANTHROPIC_API_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don’t want to expose AI keys, you can set &lt;code&gt;dry_run: 'true'&lt;/code&gt; to post a draft changelog as a PR comment instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  🌱 Distribution &amp;amp; Ecosystem
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Available as an npm/pnpm CLI package (&lt;code&gt;@nyaomaru/changelog-bot&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Also usable as a GitHub Action via the included &lt;code&gt;action.yml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The fallback logic doesn’t require Conventional Commits — easy to drop into any repo&lt;/li&gt;
&lt;li&gt;Issues and PRs are always welcome! Templates are ready, so feel free to leave feedback 😹&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Future Plans
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Support for local LLMs as changelog classifiers&lt;/li&gt;
&lt;li&gt;Multi-language changelog output&lt;/li&gt;
&lt;li&gt;Improved accuracy via preprocessing and better prompts&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🎯 Summary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;changelog-bot&lt;/code&gt; is both a &lt;strong&gt;CLI&lt;/strong&gt; and a &lt;strong&gt;GitHub Action&lt;/strong&gt; that automatically generates &lt;code&gt;CHANGELOG.md&lt;/code&gt; whenever a release is triggered.&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/nyaomaru/changelog-bot" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/changelog-bot&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you like it, don’t forget to leave a 🌟 — it keeps me going! 😻&lt;/p&gt;




&lt;h2&gt;
  
  
  Bonus
&lt;/h2&gt;

&lt;p&gt;I’ve also released another OSS project called &lt;strong&gt;is-kit&lt;/strong&gt; — a utility for building powerful &lt;code&gt;isXXX&lt;/code&gt; type guards in TypeScript.&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.amazonaws.com%2Fuploads%2Farticles%2F3ro5orqoc2fcp4osfa1z.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.amazonaws.com%2Fuploads%2Farticles%2F3ro5orqoc2fcp4osfa1z.png" alt=" " width="800" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/is-kit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Check out the related articles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://dev.to/nyaomaru/escaping-the-forest-of-if-statements-building-logical-type-guards-with-is-kit-2db3"&gt;https://dev.to/nyaomaru/escaping-the-forest-of-if-statements-building-logical-type-guards-with-is-kit-2db3&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://dev.to/nyaomaru/building-type-guards-like-lego-blocks-making-reusable-logic-with-is-kit-48e4"&gt;https://dev.to/nyaomaru/building-type-guards-like-lego-blocks-making-reusable-logic-with-is-kit-48e4&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://dev.to/nyaomaru/build-isxxx-the-easy-way-meet-is-kit-2hpl"&gt;https://dev.to/nyaomaru/build-isxxx-the-easy-way-meet-is-kit-2hpl&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>typescript</category>
      <category>ci</category>
      <category>opensource</category>
    </item>
    <item>
      <title>🧠 Understanding Variance in TypeScript &amp; Flow: Covariant, Contravariant, Invariant, Bivariant</title>
      <dc:creator>nyaomaru</dc:creator>
      <pubDate>Wed, 26 Nov 2025 05:46:26 +0000</pubDate>
      <link>https://dev.to/nyaomaru/understanding-variance-in-typescript-flow-covariant-contravariant-invariant-bivariant-4fbi</link>
      <guid>https://dev.to/nyaomaru/understanding-variance-in-typescript-flow-covariant-contravariant-invariant-bivariant-4fbi</guid>
      <description>&lt;p&gt;Hi everyone!&lt;/p&gt;

&lt;p&gt;I’m &lt;a href="https://github.com/nyaomaru" rel="noopener noreferrer"&gt;@nyaomaru&lt;/a&gt;, a frontend engineer who quietly moved to the Netherlands. 🇳🇱&lt;/p&gt;

&lt;p&gt;If you write &lt;code&gt;TypeScript&lt;/code&gt;, you’ve probably bumped into the term &lt;strong&gt;“variance”&lt;/strong&gt; at some point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;covariant&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;contravariant&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;invariant&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;bivariant&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You may have a vague feeling of “I sorta get it… but not really.”&lt;/p&gt;

&lt;p&gt;Personally, I struggled especially with &lt;strong&gt;contravariance&lt;/strong&gt; and &lt;strong&gt;bivariance&lt;/strong&gt; — they’re really counter-intuitive.&lt;/p&gt;

&lt;p&gt;And when I tried to deep-read &lt;code&gt;React&lt;/code&gt;’s type definitions, I ran into &lt;code&gt;Flow&lt;/code&gt;’s variance annotations &lt;code&gt;+T&lt;/code&gt; / &lt;code&gt;-T&lt;/code&gt; and completely froze:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Element&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;+&lt;/span&gt;&lt;span class="nx"&gt;C&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React$Element&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;C&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="nx"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RefSetter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="nx"&gt;I&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React$RefSetter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;I&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;“What are &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;-&lt;/code&gt;!?”&lt;br&gt;
“Why is &lt;code&gt;React&lt;/code&gt; using this in &lt;code&gt;Flow&lt;/code&gt;!?”&lt;/p&gt;

&lt;p&gt;That was the &lt;strong&gt;entrance&lt;/strong&gt; to understand variance for me.&lt;/p&gt;

&lt;p&gt;In this article, I’ll use both &lt;code&gt;TypeScript&lt;/code&gt; and &lt;code&gt;Flow&lt;/code&gt; to build a &lt;strong&gt;practical&lt;/strong&gt;, &lt;strong&gt;real-world&lt;/strong&gt; understanding of variance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want to read &lt;code&gt;React’s&lt;/code&gt; type definitions without crying 😿&lt;/li&gt;
&lt;li&gt;You want to design safe callback types in &lt;code&gt;TypeScript&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;You want to avoid the bivariant foot-guns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s dive in 👇&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
I won’t do a full &lt;code&gt;Flow&lt;/code&gt; tutorial here. I’ll only touch &lt;code&gt;Flow&lt;/code&gt; enough to explain how it expresses variance.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  🧩 What is type variance?
&lt;/h2&gt;

&lt;p&gt;“Type variance” is about generic types and function types, and:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;how the subtype relationship between type parameters propagates to the outer type&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For example, suppose &lt;code&gt;Cat&lt;/code&gt; is a subtype of &lt;code&gt;Animal&lt;/code&gt; (&lt;code&gt;Cat &amp;lt;: Animal&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Then is &lt;code&gt;List&amp;lt;Cat&amp;gt;&lt;/code&gt; also a subtype of &lt;code&gt;List&amp;lt;Animal&amp;gt;&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Can we use &lt;code&gt;Handler&amp;lt;Animal&amp;gt;&lt;/code&gt; where a &lt;code&gt;Handler&amp;lt;Cat&amp;gt;&lt;/code&gt; is expected?&lt;/li&gt;
&lt;li&gt;What about something mutable like &lt;code&gt;Box&amp;lt;T&amp;gt;&lt;/code&gt;?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Variance is the set of rules that determines these “generic subtype” relationships.&lt;/p&gt;

&lt;p&gt;There are four basic flavors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Covariant&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Subtype relationship goes in the &lt;strong&gt;same direction&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contravariant&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Subtype relationship goes in the &lt;strong&gt;opposite direction&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invariant&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;No subtype relationship either way&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bivariant&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Both directions are allowed (&lt;code&gt;TypeScript&lt;/code&gt; foot-gun)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are not specific to &lt;code&gt;TypeScript&lt;/code&gt; — you’ll find them in &lt;code&gt;Java&lt;/code&gt;, &lt;code&gt;C#&lt;/code&gt;, &lt;code&gt;Kotlin&lt;/code&gt;, &lt;code&gt;Flow&lt;/code&gt;, and pretty much any typed language that has generics.&lt;/p&gt;

&lt;p&gt;Let’s go through them one by one.&lt;/p&gt;
&lt;h3&gt;
  
  
  ⬆️ Covariant: “If child is OK, using it as parent is also OK”
&lt;/h3&gt;

&lt;p&gt;Given &lt;code&gt;Cat &amp;lt;: Animal&lt;/code&gt;, covariance is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Covariant: &lt;code&gt;F&amp;lt;Cat&amp;gt; &amp;lt;: F&amp;lt;Animal&amp;gt;&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Roughly: a “container of more specific things” can be used where a “container of more general things” is expected.&lt;/p&gt;

&lt;p&gt;This is typically used for &lt;strong&gt;read-only&lt;/strong&gt; types.&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;class&lt;/span&gt; &lt;span class="nc"&gt;Animal&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="s1"&gt;animal&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;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Cat&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Animal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;meow&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ReadonlyBox&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&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;catBox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReadonlyBox&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Cat&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Cat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Using "box of Cat" as "box of Animal" is fine&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;animalBox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReadonlyBox&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Animal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;catBox&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Reading as Animal is always safe&lt;/span&gt;
&lt;span class="c1"&gt;// animalBox.value.meow(); // Type error: Animal doesn't have meow()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here we only &lt;strong&gt;read&lt;/strong&gt; from the box, so it’s safe to treat a &lt;code&gt;ReadonlyBox&amp;lt;Cat&amp;gt;&lt;/code&gt; as a &lt;code&gt;ReadonlyBox&amp;lt;Animal&amp;gt;&lt;/code&gt;. That’s &lt;strong&gt;covariance&lt;/strong&gt;.&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.amazonaws.com%2Fuploads%2Farticles%2Fccipn9wpbmtyblauouf6.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.amazonaws.com%2Fuploads%2Farticles%2Fccipn9wpbmtyblauouf6.png" alt=" " width="800" height="230"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Top: &lt;code&gt;Cat&lt;/code&gt; → &lt;code&gt;Animal&lt;/code&gt; (usual subtype relation)&lt;/li&gt;
&lt;li&gt;Bottom: &lt;code&gt;ReadonlyBox&amp;lt;Cat&amp;gt;&lt;/code&gt; → &lt;code&gt;ReadonlyBox&amp;lt;Animal&amp;gt;&lt;/code&gt; (same direction → covariant)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⬇️ Contravariant: “If parent is OK, you can use it in a child-only slot”
&lt;/h3&gt;

&lt;p&gt;Again with &lt;code&gt;Cat &amp;lt;: Animal&lt;/code&gt;, contravariance is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Contravariant: &lt;code&gt;F&amp;lt;Animal&amp;gt;&lt;/code&gt; &amp;lt;: &lt;code&gt;F&amp;lt;Cat&amp;gt;&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The idea is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A function that can handle a wider type can safely be used wherever a narrower type handler is required.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This comes up with &lt;strong&gt;function parameter types&lt;/strong&gt; (i.e., “write-only” positions).&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;class&lt;/span&gt; &lt;span class="nc"&gt;Animal&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="s1"&gt;animal&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;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Cat&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Animal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;meow&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&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="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&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;handleAnimal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Animal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;animal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Animal&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;animal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&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;handleCat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Cat&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Cat&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;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;meow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Safe with contravariance:&lt;/span&gt;
&lt;span class="c1"&gt;// A handler that accepts any Animal can be used as a Cat-specific handler&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;catHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Cat&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;handleAnimal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// OK (in theory)&lt;/span&gt;

&lt;span class="c1"&gt;// The opposite is unsafe: a Cat-only handler&lt;/span&gt;
&lt;span class="c1"&gt;// cannot safely handle all Animals (Dog, Bird, ...)&lt;/span&gt;
&lt;span class="c1"&gt;// const animalHandler: Handler&amp;lt;Animal&amp;gt; = handleCat; // Should be an error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Handler&amp;lt;Animal&amp;gt;&lt;/code&gt; can handle any animal (including &lt;code&gt;Cat&lt;/code&gt;), so it’s safe to use where a “Cat handler” is expected.&lt;/p&gt;

&lt;p&gt;The reverse is &lt;strong&gt;not&lt;/strong&gt; safe → that’s contravariance.&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.amazonaws.com%2Fuploads%2Farticles%2F9knw3k5y0a4tcht7qogr.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.amazonaws.com%2Fuploads%2Farticles%2F9knw3k5y0a4tcht7qogr.png" alt=" " width="800" height="230"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Top: &lt;code&gt;Cat&lt;/code&gt; → &lt;code&gt;Animal&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Bottom: &lt;code&gt;Handler&amp;lt;Animal&amp;gt;&lt;/code&gt; → &lt;code&gt;Handler&amp;lt;Cat&amp;gt;&lt;/code&gt; (reverse direction → contravariant)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ⛔ Invariant: “No subtyping either way”
&lt;/h3&gt;

&lt;p&gt;Even if &lt;code&gt;Cat &amp;lt;: Animal&lt;/code&gt; , with invariance:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Invariant: &lt;code&gt;F&amp;lt;Cat&amp;gt;&lt;/code&gt; and &lt;code&gt;F&amp;lt;Animal&amp;gt;&lt;/code&gt; have &lt;strong&gt;no&lt;/strong&gt; subtype relation&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Animal&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="s1"&gt;animal&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;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Cat&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Animal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;meow&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;animalBox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Animal&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Animal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;catBox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Box&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Cat&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Cat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Both of these are theoretically unsafe:&lt;/span&gt;
&lt;span class="c1"&gt;//&lt;/span&gt;
&lt;span class="c1"&gt;// animalBox = catBox;&lt;/span&gt;
&lt;span class="c1"&gt;// catBox = animalBox;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you treat &lt;code&gt;Box&amp;lt;Cat&amp;gt;&lt;/code&gt; as &lt;code&gt;Box&amp;lt;Animal&amp;gt;&lt;/code&gt;:

&lt;ul&gt;
&lt;li&gt;You could write a plain &lt;code&gt;Animal&lt;/code&gt; into it&lt;/li&gt;
&lt;li&gt;But someone else might assume it still only contains &lt;code&gt;Cat&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;If you treat &lt;code&gt;Box&amp;lt;Animal&amp;gt;&lt;/code&gt; as &lt;code&gt;Box&amp;lt;Cat&amp;gt;&lt;/code&gt;:

&lt;ul&gt;
&lt;li&gt;You might pull an &lt;code&gt;Animal&lt;/code&gt; from it and assume it’s always a &lt;code&gt;Cat&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;Since it’s &lt;strong&gt;mutable&lt;/strong&gt; and used for both read and write, the safe option is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Make it invariant (no subtyping).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In pure type theory we’d reject both assignments.&lt;/p&gt;

&lt;p&gt;In practice, TypeScript treats most generics as approximately covariant, so &lt;code&gt;Box&amp;lt;Cat&amp;gt;&lt;/code&gt; → &lt;code&gt;Box&amp;lt;Animal&amp;gt;&lt;/code&gt; may compile — but conceptually, &lt;code&gt;Box&amp;lt;T&amp;gt;&lt;/code&gt; should be invariant.&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.amazonaws.com%2Fuploads%2Farticles%2Ft0ygcdcqyapn4zci25ro.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.amazonaws.com%2Fuploads%2Farticles%2Ft0ygcdcqyapn4zci25ro.png" alt=" " width="800" height="230"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Top: &lt;code&gt;Cat&lt;/code&gt; → &lt;code&gt;Animal&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Bottom: no arrow between &lt;code&gt;Box&amp;lt;Cat&amp;gt;&lt;/code&gt; and &lt;code&gt;Box&amp;lt;Animal&amp;gt;&lt;/code&gt; → invariant&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🔁 Bivariant: “Both directions are OK (and that’s exactly the problem)” — TypeScript’s hole
&lt;/h3&gt;

&lt;p&gt;Given &lt;code&gt;Cat &amp;lt;: Animal&lt;/code&gt;, bivariance is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Bivariant: &lt;code&gt;F&amp;lt;Cat&amp;gt;&lt;/code&gt; and &lt;code&gt;F&amp;lt;Animal&amp;gt;&lt;/code&gt; are mutually assignable&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So it’s like “covariant + contravariant at the same time”.&lt;br&gt;
From a soundness standpoint, this is pretty much always a bad idea.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;TypeScript&lt;/code&gt; allows it in some cases (esp. some callback parameter types) for &lt;code&gt;JavaScript&lt;/code&gt; compatibility.&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;type&lt;/span&gt; &lt;span class="nx"&gt;EventHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;handleEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EventHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;handleMouse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EventHandler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MouseEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// In sound type theory, only one of these would be allowed (depending on design).&lt;/span&gt;
&lt;span class="c1"&gt;// In TypeScript, *both* can be allowed in certain contexts (bivariant).&lt;/span&gt;
&lt;span class="nx"&gt;handleEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;handleMouse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// OK in some cases (but can be unsafe)&lt;/span&gt;
&lt;span class="nx"&gt;handleMouse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;handleEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Also OK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;MouseEvent&lt;/code&gt; is a subtype of &lt;code&gt;Event&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If both directions are allowed, you can pass the “wrong” handler&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TypeScript&lt;/code&gt; chooses practicality over strict safety here&lt;/li&gt;
&lt;/ul&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.amazonaws.com%2Fuploads%2Farticles%2F6f7xvofg602t5pi5539e.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.amazonaws.com%2Fuploads%2Farticles%2F6f7xvofg602t5pi5539e.png" alt=" " width="800" height="230"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Top: &lt;code&gt;MouseEvent&lt;/code&gt; → &lt;code&gt;Event&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Bottom: &lt;code&gt;Handler&amp;lt;MouseEvent&amp;gt;&lt;/code&gt; ⇔ &lt;code&gt;Handler&amp;lt;Event&amp;gt;&lt;/code&gt; → bivariant&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  🎯 Quick summary of the four
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Safety&lt;/th&gt;
&lt;th&gt;Typical example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Covariant&lt;/td&gt;
&lt;td&gt;Child → Parent&lt;/td&gt;
&lt;td&gt;Safe for reads&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;readonly T[]&lt;/code&gt;, &lt;code&gt;ReadonlyArray&amp;lt;T&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contravariant&lt;/td&gt;
&lt;td&gt;Parent → Child&lt;/td&gt;
&lt;td&gt;Safe for writes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;(arg: T) =&amp;gt; void&lt;/code&gt;, handlers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invariant&lt;/td&gt;
&lt;td&gt;No subtyping&lt;/td&gt;
&lt;td&gt;Safe for mutable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Box&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bivariant&lt;/td&gt;
&lt;td&gt;Both directions&lt;/td&gt;
&lt;td&gt;Unsound / risky&lt;/td&gt;
&lt;td&gt;Some TS callback parameter positions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  ⚙️ &lt;code&gt;TypeScript&lt;/code&gt;’s reality vs. “pure” type theory
&lt;/h2&gt;

&lt;p&gt;So far we’ve been in the “beautiful, clean theory world”:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read-only → &lt;strong&gt;covariant&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Write-only (function parameters) → &lt;strong&gt;contravariant&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Mutable read + write → &lt;strong&gt;invariant&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Both directions → &lt;strong&gt;unsound (bivariant)&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But &lt;code&gt;TypeScript&lt;/code&gt; does not fully implement this ideal.&lt;/p&gt;

&lt;p&gt;Because of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Historical &lt;code&gt;JavaScript&lt;/code&gt; APIs&lt;/li&gt;
&lt;li&gt;Massive existing &lt;code&gt;JavaScript&lt;/code&gt; codebases&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Browser&lt;/code&gt; / &lt;code&gt;DOM&lt;/code&gt; APIs design&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;TypeScript&lt;/code&gt; makes several pragmatic compromises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;T[]&lt;/code&gt; arrays are &lt;strong&gt;treated as covariant&lt;/strong&gt;, even though they should be invariant&lt;/li&gt;
&lt;li&gt;Function parameter positions are &lt;strong&gt;often bivariant&lt;/strong&gt;, not strict contravariant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This mismatch between &lt;strong&gt;type theory intuition&lt;/strong&gt; and &lt;strong&gt;TS behavior&lt;/strong&gt; is where a lot of confusion comes from.&lt;/p&gt;

&lt;p&gt;Let’s look at the two big ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Arrays: should be invariant, but TS treats them as “basically covariant”
&lt;/h3&gt;

&lt;p&gt;Formally:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;T[]&lt;/code&gt; should really be &lt;strong&gt;invariant&lt;/strong&gt;&lt;br&gt;
but &lt;code&gt;TypeScript&lt;/code&gt; treats it as if it were &lt;strong&gt;covariant&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which means some unsafe code compiles.&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;class&lt;/span&gt; &lt;span class="nc"&gt;Animal&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="s1"&gt;animal&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;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Cat&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Animal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;meow&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Dog&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Animal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;bark&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Cat&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;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Cat&lt;/span&gt;&lt;span class="p"&gt;()];&lt;/span&gt;

&lt;span class="c1"&gt;// TS treats Cat[] as a subtype of Animal[]&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;animals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Animal&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cats&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Now this is allowed:&lt;/span&gt;
&lt;span class="nx"&gt;animals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Dog&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// But the underlying array is still "cats"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Cat&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cats&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="c1"&gt;// Type says Cat, but it's actually a Dog&lt;/span&gt;
&lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;meow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Possible runtime error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why does TS allow this?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;From a type theory perspective, &lt;code&gt;T[]&lt;/code&gt; is a mutable container → should be invariant&lt;/li&gt;
&lt;li&gt;But &lt;code&gt;JavaScript&lt;/code&gt;’s arrays are extremely flexible and historically treated loosely&lt;/li&gt;
&lt;li&gt;Making &lt;code&gt;T[]&lt;/code&gt; strictly invariant would break a lot of existing &lt;code&gt;JavaScript&lt;/code&gt; code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;TypeScript&lt;/code&gt; chose to keep &lt;code&gt;T[]&lt;/code&gt; almost &lt;strong&gt;covariant&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The recommended alternative is &lt;strong&gt;read-only arrays&lt;/strong&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;Cat&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;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Cat&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;animals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;Animal&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cats&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Safe&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because we only read from a &lt;code&gt;readonly&lt;/code&gt; array, it can be safely covariant.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In practice: most generics in TS behave “mostly covariant”.&lt;br&gt;
The real danger is specifically “mutable but treated as covariant”, like &lt;code&gt;T[]&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Function parameters: should be contravariant, but often behave bivariantly
&lt;/h3&gt;

&lt;p&gt;The second big compromise: &lt;strong&gt;function parameter variance&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In theory:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Function &lt;strong&gt;parameter&lt;/strong&gt; positions should be &lt;strong&gt;contravariant&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In practice, TypeScript often treats them as &lt;strong&gt;bivariant&lt;/strong&gt;, especially in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Methods in object types&lt;/li&gt;
&lt;li&gt;Some callback positions&lt;/li&gt;
&lt;li&gt;When &lt;code&gt;strictFunctionTypes&lt;/code&gt; is disabled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s see why that’s a problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why should parameters be contravariant?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&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="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;T&lt;/code&gt; is used in &lt;strong&gt;parameter position&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It’s on the “receiving data” side&lt;/li&gt;
&lt;li&gt;Which means it should be &lt;strong&gt;contravariant&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If &lt;code&gt;Cat &amp;lt;: Animal&lt;/code&gt;, then (in theory):&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;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Animal&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="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Cat&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We’ve already seen this pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  But TS sometimes allows both directions (bivariant)
&lt;/h3&gt;

&lt;p&gt;To maintain compatibility with existing &lt;code&gt;JavaScript&lt;/code&gt;, TS allows both directions in many callback cases:&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;type&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&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="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;handleEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;handleMouse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MouseEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// In some contexts, TS allows:&lt;/span&gt;
&lt;span class="nx"&gt;handleEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;handleMouse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// OK&lt;/span&gt;
&lt;span class="nx"&gt;handleMouse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;handleEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// OK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;With a purely sound type system, one of these would be rejected.&lt;br&gt;
TS chooses convenience over strict safety.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Consider this more dangerous version:&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;type&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&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="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&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;handleEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;);&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;handleMouse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MouseEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&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="c1"&gt;// MouseEvent-specific API&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Assign MouseEvent handler to a generic Event handler&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;handleMouse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// This is allowed by the type system:&lt;/span&gt;
&lt;span class="nf"&gt;eventHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// Runtime error: no clientX&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This &lt;strong&gt;should&lt;/strong&gt; be a type error, but bivariant behavior lets it through.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did the TS team do this?
&lt;/h3&gt;

&lt;p&gt;Because real-world &lt;code&gt;JavaScript&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Doesn’t assume strict contravariance&lt;/li&gt;
&lt;li&gt;Has tons of “loosely typed” callback APIs (&lt;code&gt;DOM&lt;/code&gt;, &lt;code&gt;Node.js&lt;/code&gt;, libraries, …)&lt;/li&gt;
&lt;li&gt;Would break massively if TS suddenly enforced full contravariance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the TS team made a pragmatic choice:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Prioritize developer experience and compatibility&lt;br&gt;
over 100% soundness.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  What about &lt;code&gt;strictFunctionTypes&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;There is a partial escape hatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&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;span class="nl"&gt;"strict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"strictFunctionTypes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;strictFunctionTypes: true&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standalone function types&lt;/strong&gt; are treated more contravariantly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Methods in object types&lt;/strong&gt; are still treated more loosely (bivariantly) for compatibility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So even in strict mode, you don’t get “perfect” contravariance — but you get closer.&lt;/p&gt;

&lt;h3&gt;
  
  
  🎯 Takeaway: &lt;code&gt;TypeScript&lt;/code&gt; is not a “pure variance lab”
&lt;/h3&gt;

&lt;p&gt;To summarize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Arrays &lt;code&gt;T[]&lt;/code&gt; should be invariant → TS treats them as almost covariant&lt;/li&gt;
&lt;li&gt;Function parameters should be contravariant → TS often treats them as bivariant&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;strictFunctionTypes&lt;/code&gt; helps, but doesn’t give you fully sound variance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;TypeScript&lt;/code&gt; is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;not a perfectly sound type theory playground,&lt;br&gt;
but a pragmatic type system sitting on top of messy real-world JavaScript.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Variance in TS is “good enough” most of the time, but you should be aware of where it leaks.&lt;/p&gt;




&lt;h2&gt;
  
  
  🌊 How Flow expresses variance explicitly
&lt;/h2&gt;

&lt;p&gt;Quick recap: &lt;code&gt;Flow&lt;/code&gt; is a static type checker for &lt;code&gt;JavaScript&lt;/code&gt;, originally developed at &lt;code&gt;Facebook&lt;/code&gt; (now &lt;code&gt;Meta&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Key characteristics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More &lt;strong&gt;strict&lt;/strong&gt; and &lt;strong&gt;soundness-oriented&lt;/strong&gt; than TS&lt;/li&gt;
&lt;li&gt;Variance is &lt;strong&gt;explicitly&lt;/strong&gt; annotated&lt;/li&gt;
&lt;li&gt;Strong type inference&lt;/li&gt;
&lt;li&gt;Type annotations layered onto plain JS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In very rough terms:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;TypeScript&lt;/code&gt; focuses on practicality&lt;br&gt;
&lt;code&gt;Flow&lt;/code&gt; focuses more on safety&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One of Flow’s signature features is &lt;strong&gt;explicit variance annotations&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Variance annotations in Flow
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;Flow&lt;/code&gt;, you write variance directly on type parameters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+T&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;covariant&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-T&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;contravariant&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;T&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;invariant&lt;/strong&gt;(no sign)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So in &lt;code&gt;Flow&lt;/code&gt;, the author of a type explicitly declares:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“This type parameter is used covariantly / contravariantly / invariantly.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In &lt;code&gt;TypeScript&lt;/code&gt;, the compiler mostly infers this.&lt;br&gt;
In &lt;code&gt;Flow&lt;/code&gt;, you annotate it, and Flow checks that your usage matches.&lt;/p&gt;


&lt;h2&gt;
  
  
  ⚛️ How React uses variance in Flow types
&lt;/h2&gt;

&lt;p&gt;React’s internal types were historically written in Flow, and you can still see traces of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Element&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;+&lt;/span&gt;&lt;span class="nx"&gt;C&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React$Element&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;C&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="nx"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RefSetter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="nx"&gt;I&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React$RefSetter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;I&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;ul&gt;
&lt;li&gt;
&lt;code&gt;+C&lt;/code&gt; is covariant&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-I&lt;/code&gt; is contravariant&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why is Element&amp;lt;+C&amp;gt; covariant?
&lt;/h3&gt;

&lt;p&gt;Because &lt;code&gt;ReactElement&lt;/code&gt; is essentially &lt;strong&gt;immutable&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You construct it&lt;/li&gt;
&lt;li&gt;You read from it&lt;/li&gt;
&lt;li&gt;You don’t mutate its props in place&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So it’s safe to say that if &lt;code&gt;CatProps &amp;lt;: AnimalProps&lt;/code&gt;, then:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;ReactElement&amp;lt;CatProps&amp;gt; &amp;lt;: ReactElement&amp;lt;AnimalProps&amp;gt;&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Example:&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AnimalProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CatProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&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="nl"&gt;meow&lt;/span&gt;&lt;span class="p"&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="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Cat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CatProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;;&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;catElement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactElement&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CatProps&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Cat&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"nyaomaru"&lt;/span&gt; &lt;span class="na"&gt;meow&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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="p"&gt;{}&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;);&lt;/span&gt;

&lt;span class="c1"&gt;// Because of covariance, this is safe:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactElement&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AnimalProps&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;catElement&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;A component that accepts “more specific props” can be used where “more general props” are expected&lt;/li&gt;
&lt;li&gt;The element is read-only — we’re not mutating its props through this type&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why is RefSetter&amp;lt;-I&amp;gt; contravariant?
&lt;/h3&gt;

&lt;p&gt;Because &lt;code&gt;RefSetter&lt;/code&gt; &lt;strong&gt;receives&lt;/strong&gt; values (instances) — it doesn’t produce them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It’s on the “write side”&lt;/li&gt;
&lt;li&gt;That’s a &lt;strong&gt;contravariant&lt;/strong&gt; position&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example:&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;type&lt;/span&gt; &lt;span class="nx"&gt;HTMLDivRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLDivElement&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;HTMLElementRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We have &lt;code&gt;HTMLDivElement &amp;lt;: HTMLElement&lt;/code&gt;, and with contravariance:&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;RefSetter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLElement&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="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RefSetter&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A callback that can handle any &lt;code&gt;HTMLElement&lt;/code&gt; can safely be used where a “div-only” ref setter is expected&lt;/li&gt;
&lt;li&gt;But the opposite is unsafe: a &lt;code&gt;HTMLDivElement&lt;/code&gt;-only ref setter cannot safely accept any &lt;code&gt;HTMLElement&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matches exactly our earlier &lt;code&gt;Handler&amp;lt;T&amp;gt;&lt;/code&gt; examples.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this matters for reading React’s types
&lt;/h3&gt;

&lt;p&gt;Once you understand &lt;code&gt;Flow&lt;/code&gt;’s variance annotations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;+C&lt;/code&gt;(covariant) on &lt;code&gt;ReactElement&amp;lt;+C&amp;gt;&lt;/code&gt; → safe, immutable, read-only&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-T&lt;/code&gt; (contravariant) on &lt;code&gt;RefSetter&amp;lt;-T&amp;gt;&lt;/code&gt; → callback parameter, write-only&lt;/li&gt;
&lt;li&gt;No sign → invariant types where mutation may happen&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes React’s Flow types much easier to reason about.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ Where variance actually matters in real code
&lt;/h2&gt;

&lt;p&gt;This may still feel theoretical, but it absolutely shows up in day-to-day work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;onClick&lt;/code&gt;, &lt;code&gt;onChange&lt;/code&gt;, &lt;code&gt;onSubmit&lt;/code&gt; → UI event handlers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;onSuccess&lt;/code&gt;, &lt;code&gt;onError&lt;/code&gt; → async/API callbacks&lt;/li&gt;
&lt;li&gt;Exposed “handler” or “listener” APIs in your own libraries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these are basically:&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;type&lt;/span&gt; &lt;span class="nx"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&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="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parameter types → &lt;strong&gt;contravariant&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Return types → &lt;strong&gt;covariant&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Mutable containers → &lt;strong&gt;invariant&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;TS callback parameters in methods → often &lt;strong&gt;bivariant&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When designing public APIs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prefer&lt;/strong&gt; wider types for parameters your consumers pass in&lt;/li&gt;
&lt;li&gt;Avoid exposing raw mutable containers like &lt;code&gt;Box&amp;lt;T&amp;gt;&lt;/code&gt; when a &lt;code&gt;ReadonlyBox&amp;lt;T&amp;gt;&lt;/code&gt; will do&lt;/li&gt;
&lt;li&gt;Consider using &lt;code&gt;readonly&lt;/code&gt; arrays and properties when you can&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Variance is a mental checklist for API design, not something you only care about in textbooks.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  📌 Wrap-up
&lt;/h2&gt;

&lt;p&gt;We covered a lot, so let’s distill the main points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Variance determines how subtyping of type parameters affects the outer type:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Covariant&lt;/strong&gt; → same direction (usually read-only)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contravariant&lt;/strong&gt; → opposite direction (usually function parameters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invariant&lt;/strong&gt; → no subtyping either way (mutable containers)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bivariant&lt;/strong&gt; → both directions (convenient but unsound)&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;TypeScript:

&lt;ul&gt;
&lt;li&gt;Treats many generics as “mostly covariant”&lt;/li&gt;
&lt;li&gt;Makes &lt;code&gt;T[]&lt;/code&gt; effectively covariant (even though it should be invariant)&lt;/li&gt;
&lt;li&gt;Often treats function parameters as bivariant&lt;/li&gt;
&lt;li&gt;Provides &lt;code&gt;strictFunctionTypes&lt;/code&gt; to tighten some of this&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;Flow:

&lt;ul&gt;
&lt;li&gt;Has explicit variance annotations (&lt;code&gt;+T&lt;/code&gt;, &lt;code&gt;-T&lt;/code&gt;, &lt;code&gt;T&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;Practically:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;readonly&lt;/code&gt; vs mutable is a variance decision&lt;/li&gt;
&lt;li&gt;Callback parameter types are a variance decision&lt;/li&gt;
&lt;li&gt;Whether you expose &lt;code&gt;Box&amp;lt;T&amp;gt;&lt;/code&gt; or &lt;code&gt;ReadonlyBox&amp;lt;T&amp;gt;&lt;/code&gt; is a variance decision&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;You don’t need to memorize all the formal rules,&lt;br&gt;
but keeping “covariant / contravariant / invariant / bivariant” in your mental toolbox makes both &lt;strong&gt;reading&lt;/strong&gt; library types and &lt;strong&gt;designing&lt;/strong&gt; your own APIs much easier.&lt;/p&gt;

&lt;p&gt;I also made a small repo where you can play with these variance patterns in TypeScript:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/nyaomaru/variance-check" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/variance-check&lt;/a&gt;&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.amazonaws.com%2Fuploads%2Farticles%2F024tr0vk866y6rm3bkea.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.amazonaws.com%2Fuploads%2Farticles%2F024tr0vk866y6rm3bkea.png" alt=" " width="800" height="285"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Have a nice variance life 🐈‍⬛✨&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Type_variance" rel="noopener noreferrer"&gt;https://en.wikipedia.org/wiki/Type_variance&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.typescriptlang.org/docs/handbook/2/generics.html#variance-annotations" rel="noopener noreferrer"&gt;https://www.typescriptlang.org/docs/handbook/2/generics.html#variance-annotations&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.totaltypescript.com/method-shorthand-syntax-considered-harmful" rel="noopener noreferrer"&gt;https://www.totaltypescript.com/method-shorthand-syntax-considered-harmful&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.sandromaglione.com/articles/covariant-contravariant-and-invariant-in-typescript" rel="noopener noreferrer"&gt;https://www.sandromaglione.com/articles/covariant-contravariant-and-invariant-in-typescript&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://zenn.dev/jay_es/articles/2024-02-13-typescript-variance" rel="noopener noreferrer"&gt;https://zenn.dev/jay_es/articles/2024-02-13-typescript-variance&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://typescriptbook.jp/reference/generics/variance" rel="noopener noreferrer"&gt;https://typescriptbook.jp/reference/generics/variance&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://effectivetypescript.com/2021/05/06/unsoundness/" rel="noopener noreferrer"&gt;https://effectivetypescript.com/2021/05/06/unsoundness/&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🐾 Shameless plug
&lt;/h2&gt;

&lt;p&gt;When you write &lt;code&gt;TypeScript&lt;/code&gt;, you probably end up writing &lt;code&gt;isXXX&lt;/code&gt; guards over and over.&lt;br&gt;
It’s boring and error-prone, so I built a small OSS library to help:&lt;/p&gt;

&lt;p&gt;👉 &lt;code&gt;is-kit&lt;/code&gt;: a tiny toolkit for building composable, type-safe type guards&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.amazonaws.com%2Fuploads%2Farticles%2Fkd71eb31m2x63vh1pzey.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.amazonaws.com%2Fuploads%2Farticles%2Fkd71eb31m2x63vh1pzey.png" alt=" " width="800" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nyaomaru/is-kit" rel="noopener noreferrer"&gt;https://github.com/nyaomaru/is-kit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you’re curious, I also wrote about it here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/nyaomaru/build-isxxx-the-easy-way-meet-is-kit-2hpl"&gt;https://dev.to/nyaomaru/build-isxxx-the-easy-way-meet-is-kit-2hpl&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/nyaomaru/building-type-guards-like-lego-blocks-making-reusable-logic-with-is-kit-48e4"&gt;https://dev.to/nyaomaru/building-type-guards-like-lego-blocks-making-reusable-logic-with-is-kit-48e4&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/nyaomaru/escaping-the-forest-of-if-statements-building-logical-type-guards-with-is-kit-2db3"&gt;https://dev.to/nyaomaru/escaping-the-forest-of-if-statements-building-logical-type-guards-with-is-kit-2db3&lt;/a&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>react</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
