<?xml version='1.0' encoding='UTF-8'?><?xml-stylesheet href="http://www.blogger.com/styles/atom.css" type="text/css"?><feed xmlns='http://www.w3.org/2005/Atom' xmlns:openSearch='http://a9.com/-/spec/opensearchrss/1.0/' xmlns:blogger='http://schemas.google.com/blogger/2008' xmlns:georss='http://www.georss.org/georss' xmlns:gd="http://schemas.google.com/g/2005" xmlns:thr='http://purl.org/syndication/thread/1.0'><id>tag:blogger.com,1999:blog-8141574561530432909</id><updated>2026-03-27T23:25:43.373+00:00</updated><category term="CFML"/><category term="ColdFusion"/><category term="PHP"/><category term="Adobe"/><category term="Railo"/><category term="Bugs"/><category term="ColdFusion 11"/><category term="Code Examples"/><category term="Community Members"/><category term="Unit Testing"/><category term="Lucee"/><category term="Blog"/><category term="Survey"/><category term="ColdFusion 10"/><category term="TDD"/><category term="Rhetoric"/><category term="ColdFusion 2016"/><category term="Off Topic"/><category term="Sean Corfield"/><category term="Frameworks"/><category term="Courting Controversy"/><category term="StackOverflow"/><category term="Javascript"/><category term="Adam Tuttle"/><category term="Code Puzzle"/><category term="Ray Camden"/><category term="Symfony"/><category term="Theory"/><category term="Closure"/><category term="Brad Wood"/><category term="Conference"/><category term="Kotlin"/><category term="TestBox"/><category term="CFClient"/><category term="CFlib"/><category term="Docker"/><category term="OpenBD"/><category term="Rakshith Naresh"/><category term="VueJs/Symfony/Docker/TDD series"/><category term="CFCamp"/><category term="Regular expressions"/><category term="Documentation"/><category term="Micha Offner-Streit"/><category term="PHP 7"/><category term="Rupesh Kumar"/><category term="Application.cfc"/><category term="Ruby"/><category term="Arrays"/><category term="Brian Sadler"/><category term="ColdFusion 9"/><category term="Java"/><category term="PHPUnit"/><category term="Russ Michaels"/><category term="Alex Skinner"/><category term="ColdFusion Builder"/><category term="cf.Objective()"/><category term="CFSummit"/><category term="CFWheels"/><category term="CfmlNotifier"/><category term="Gavin Pickin"/><category term="JSON"/><category term="Ryan Guill"/><category term="Andrew Myers"/><category term="CFMLDeveloper"/><category term="GuzzleHttp"/><category term="Interfaces"/><category term="Mark Drew"/><category term="CFHour"/><category term="Clean Code"/><category term="Design Patterns"/><category term="Duncan Cumming"/><category term="REST"/><category term="Custom Tags"/><category term="Dave Ferguson"/><category term="Gert Franz"/><category term="Higher-order functions"/><category term="Luis Majano"/><category term="Dominic Watson"/><category term="Mingo Hagen"/><category term="Scott Stroz"/><category term="TinyTestFramework"/><category term="guest author"/><category term="Andrew Scott"/><category term="Anit Kumar Panda"/><category term="Henry Ho"/><category term="Lucee/CFWheels/Docker series"/><category term="Python"/><category term="Simon Baynes"/><category term="Static"/><category term="TypeScript"/><category term="Aaron Neff"/><category term="Acker Apple"/><category term="CFUI"/><category term="Chris Kobrzak"/><category term="Countdown to pending announcement"/><category term="Enigmatic"/><category term="Groovy"/><category term="Higher-order functions: tags to script comparison series"/><category term="Kai Koenig"/><category term="Matt Bourke"/><category term="Mike Hnat"/><category term="Mockbox"/><category term="Nginx"/><category term="Silex"/><category term="SotR"/><category term="Vue.js"/><category term="WebSockets"/><category term="Andrew Dixon"/><category term="Beer"/><category term="Ben Nadel"/><category term="CFML24h"/><category term="Clojure"/><category term="Coldbox"/><category term="Frank Jennings"/><category term="Generators"/><category term="Go"/><category term="Lunacy"/><category term="Mark Mandel"/><category term="Mixins"/><category term="PHP8"/><category term="Postcode webservice"/><category term="RWC"/><category term="Railo 5"/><category term="Rob Glover"/><category term="Ron Stewart"/><category term="Symfony the Fast Track"/><category term="Abram Adams"/><category term="Adam Presley"/><category term="Amar Parmar"/><category term="Andy Allan"/><category term="Bruce Kirkpatrick"/><category term="Cameron is a dick"/><category term="Dan Fredericks"/><category term="Dan Skaggs"/><category term="Dave McGuigan"/><category term="David Epler"/><category term="James Harvey"/><category term="Jason Dean"/><category term="John Whish"/><category term="Kotest"/><category term="Mocha"/><category term="Peter Lafferty"/><category term="Promises"/><category term="Snake Oil"/><category term="The Departed"/><category term="Carl Von Stetten"/><category term="Charlie Arehart"/><category term="ColdFusion 2016+1"/><category term="ColdFusion 5"/><category term="Decorator Pattern"/><category term="Dependency Injection"/><category term="Docker Swarm"/><category term="Estragon"/><category term="Geoff Bowers"/><category term="Gradle"/><category term="Igal Sapir"/><category term="JUnit"/><category term="James Moberg"/><category term="Kahlan"/><category term="Ktor"/><category term="London Railo Meet-up"/><category term="MVC"/><category term="MXUnit"/><category term="MariaDB"/><category term="Mike Henke"/><category term="Multi-threading"/><category term="Nicholas Claaszen"/><category term="Puppeteer"/><category term="Russell Whitter"/><category term="Sharon DiOrio"/><category term="Shawn Holmes"/><category term="Slack"/><category term="Steve Neiland"/><category term="Symfony Messaging"/><category term="Symfony Scheduler"/><category term="Twig"/><category term="Working Code Podcast"/><category term="7Li7W"/><category term="AI"/><category term="Application security"/><category term="Aurelien Deleusiere"/><category term="C#"/><category term="Code Review"/><category term="ColdFusion 8"/><category term="Coldspring"/><category term="CommandBox"/><category term="Composer"/><category term="Dates"/><category term="Dave White"/><category term="David Boyer"/><category term="Doctrine"/><category term="Elasticsearch"/><category term="Francesco Allara"/><category term="Git"/><category term="Himavanth Rachamsetty"/><category term="Iwan Dessers"/><category term="James Mohler"/><category term="Jay Cunnington"/><category term="Jessica Kennedy"/><category term="Kev McCabe"/><category term="Kyle Macey"/><category term="Lucee 5"/><category term="Madison Murphy"/><category term="Marcus Fernstrom"/><category term="Mary-Jo Sminkey"/><category term="Matt Gifford"/><category term="Matt Woodward"/><category term="Nick Kwiatkowski"/><category term="Peter Boughton"/><category term="Pimple"/><category term="QoQ"/><category term="Rob Bilson"/><category term="Salted"/><category term="Stephen Walker"/><category term="Tim Cunningham"/><category term="Tom Chiverton"/><category term="Tomcat"/><category term="Tony Junkes"/><category term="Uday Ogra"/><category term="Wolpertinger"/><category term="#favoriteCFML"/><category term="Adam Cameron"/><category term="Andrew Jackson"/><category term="AspectMock"/><category term="BD.NET"/><category term="Bad journalism"/><category term="Bad press"/><category term="Bash"/><category term="Ben Brumm"/><category term="Ben Forta"/><category term="Ben Koshy"/><category term="Big Meany"/><category term="CFConfig"/><category term="CFFORM"/><category term="CFMX 7"/><category term="CFScript"/><category term="CFSearching"/><category term="CLI"/><category term="Carol Hamilton"/><category term="Chai"/><category term="Charlie Griefer"/><category term="Cold Fusion 2"/><category term="Cold Fusion 3"/><category term="ColdFusion 11.5"/><category term="ColdFusion 2021"/><category term="ColdFusion 2023"/><category term="ColdFusion 4.5"/><category term="Cormac Parle"/><category term="DBAL"/><category term="DI/1"/><category term="Dale Fraser"/><category term="Dan Kraus"/><category term="Daniel Jansen"/><category term="Dara McGann"/><category term="Dave Harris"/><category term="David Lauridsen"/><category term="David Mulder"/><category term="David Nash"/><category term="Denard Springle"/><category term="Denny Valliant"/><category term="Docker Secrets"/><category term="Elishia Dvorak"/><category term="Elliott Sprehn"/><category term="Erosion of will to live"/><category term="GaryF"/><category term="Hair Care"/><category term="Hurricane Sandy"/><category term="JDBC"/><category term="JSoup"/><category term="Jared Evans"/><category term="Jared Rypka-Hauer"/><category term="Jasmine"/><category term="JetBrains-Exposed"/><category term="Joe Rinehart"/><category term="John Farrar"/><category term="Jorge Reyes"/><category term="Jose Galdamez"/><category term="Julian Halliwell"/><category term="Kalle Sommer Nielsen"/><category term="Kubernetes"/><category term="LRG"/><category term="Laravel"/><category term="Law of Demeter"/><category term="LogBox"/><category term="Lovable"/><category term="Marc Garner"/><category term="Monolog"/><category term="Mr Blue"/><category term="Mr Green"/><category term="Mr Purple"/><category term="Mr Red"/><category term="Mrs Now!"/><category term="NetBeans"/><category term="OOP"/><category term="ORM"/><category term="PDFs"/><category term="PDO"/><category term="PHPMD"/><category term="PHPStorm"/><category term="ProxySQL"/><category term="RabbitMQ"/><category term="Raspberry Pi"/><category term="Redis"/><category term="Refactoring"/><category term="Roberto Marzialetti"/><category term="SOAP"/><category term="Sebastian Bergmann"/><category term="Shilpi Khariwal"/><category term="Single Responsibility Principle"/><category term="Sinon"/><category term="Sublime Text"/><category term="Supabase"/><category term="Swearing"/><category term="Symfony Events"/><category term="Symfony Forms"/><category term="Symfony Locking"/><category term="Symfony Mailer"/><category term="Symfony Validator"/><category term="Todd Rafferty"/><category term="Tom King"/><category term="Twisted Sister"/><category term="Vamseekrishna Manneboina"/><category term="WireBox"/><category term="Zac Spitzer"/><category term="donkey"/><category term="himansu"/><category term="spatie/async"/><category term="xpath"/><title type='text'>Adam Cameron&#39;s Dev Blog</title><subtitle type='html'>&quot;Adam Cameron - what is this guy&#39;s deal?&quot; - DJgrassyknoll</subtitle><link rel='http://schemas.google.com/g/2005#feed' type='application/atom+xml' href='https://blog.adamcameron.me/feeds/posts/default'/><link rel='self' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default?max-results=10'/><link rel='alternate' type='text/html' href='https://blog.adamcameron.me/'/><link rel='hub' href='http://pubsubhubbub.appspot.com/'/><link rel='next' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default?start-index=11&amp;max-results=10'/><author><name>Adam Cameron</name><uri>http://www.blogger.com/profile/04830762402027484810</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author><generator version='7.00' uri='http://www.blogger.com'>Blogger</generator><openSearch:totalResults>1514</openSearch:totalResults><openSearch:startIndex>1</openSearch:startIndex><openSearch:itemsPerPage>10</openSearch:itemsPerPage><entry><id>tag:blogger.com,1999:blog-8141574561530432909.post-3252238250263397413</id><published>2026-01-22T23:14:00.001+00:00</published><updated>2026-01-22T23:14:36.548+00:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="AI"/><category scheme="http://www.blogger.com/atom/ns#" term="Lovable"/><category scheme="http://www.blogger.com/atom/ns#" term="Supabase"/><title type='text'>Setting up dev/prod environments with Lovable and Supabase</title><content type='html'>&lt;p&gt;G&#39;day:&lt;/p&gt;

&lt;p&gt;This is one of those &quot;we really should have done this from the start&quot; situations that catches up with you eventually.&lt;/p&gt;

&lt;p&gt;We&#39;ve been building an e-commerce admin application using &lt;a href=&quot;https://lovable.dev/&quot; target=&quot;_blank&quot;&gt;Lovable&lt;/a&gt; (an AI coding platform that generates React/TypeScript apps with &lt;a href=&quot;https://supabase.com/&quot; target=&quot;_blank&quot;&gt;Supabase&lt;/a&gt; backends). For context, Lovable is one of those tools where you describe what you want in natural language (vibe-coding [muttermutter]), and it generates working code with database migrations and everything. Works surprisingly well, actually.&lt;/p&gt;

&lt;p&gt;The problem: we&#39;d been developing everything directly in what would eventually become the production environment. Single Supabase instance, no separation between dev work and live system. Every code change, every database migration, every &quot;let&#39;s try this and see what happens&quot; experiment - all happening in the same environment that would eventually serve real users.&lt;/p&gt;

&lt;p&gt;This is fine when you&#39;re prototyping. It&#39;s less fine when the Product Owner has been merging features over the Christmas break and you&#39;ve suddenly got 9 pull requests to audit before you can safely call anything &quot;production ready&quot;.&lt;/p&gt;

&lt;p&gt;Time to sort out proper dev/test/prod environments. How hard could it be?&lt;/p&gt;


&lt;h3&gt;The single environment problem&lt;/h3&gt;

&lt;p&gt;Before we get into the solution, let&#39;s be clear about what was wrong with the setup.&lt;/p&gt;

&lt;p&gt;We had one Supabase project. Everything happened there:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Development work from Lovable&lt;/li&gt;
  &lt;li&gt;Database migrations as they were generated&lt;/li&gt;
  &lt;li&gt;Test data mixed with what would eventually be real data&lt;/li&gt;
  &lt;li&gt;Edge functions being deployed and redeployed&lt;/li&gt;
  &lt;li&gt;Configuration secrets that would need to be different in production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow was: make changes in Lovable, push to GitHub, review the PR, merge. Rinse and repeat. No smoke testing in a separate environment, no way to verify migrations wouldn&#39;t break things, no safety net.&lt;/p&gt;

&lt;p&gt;This meant we couldn&#39;t safely experiment. Every &quot;what if we tried this approach?&quot; question carried the risk of breaking the only database we had. And with a small team, there&#39;s no coordination between what one person is working on and what another person might be testing.&lt;/p&gt;

&lt;p&gt;The obvious solution: separate Supabase instances for dev and prod, with proper deployment workflows between them. Standard stuff, really. Except Lovable&#39;s documentation barely mentions this scenario, and Supabase has some non-obvious behaviours around how environment separation actually works.&lt;/p&gt;




&lt;h3&gt;Lovable Cloud: the auto-provisioning disaster&lt;/h3&gt;

&lt;p&gt;We&#39;d actually tried to set up separate environments once before, and it went spectacularly wrong.&lt;/p&gt;

&lt;p&gt;The plan was simple: create a new Lovable project, connect it to our existing Supabase instance, start building features. Lovable has an option to use an external Supabase project rather than having Lovable manage everything, so we configured that upfront.&lt;/p&gt;

&lt;p&gt;Except before we could do any actual work, Lovable forced us to enable &quot;Lovable Cloud&quot;. This wasn&#39;t presented as optional - it was a &quot;you must do this to proceed&quot; situation. Fair enough, we thought, probably just some hosting infrastructure it needs.&lt;/p&gt;

&lt;p&gt;Wrong.&lt;/p&gt;

&lt;p&gt;Enabling Lovable Cloud auto-provisioned a completely different Supabase instance and ignored our pre-configured external connection entirely. Login attempts started failing with HTTP 400 errors because the frontend was trying to authenticate against the wrong database. The browser console showed requests going to &lt;samp&gt;cfjdkppbukvajhaqmoon.supabase.co&lt;/samp&gt; when we&#39;d explicitly configured it to use &lt;samp&gt;twvzqadjueqejcsrtaed.supabase.co&lt;/samp&gt;.&lt;/p&gt;

&lt;p&gt;It turns out Lovable has two completely different modes:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Lovable Cloud&lt;/strong&gt; - auto-provisions its own Supabase, manages everything, cannot be overridden&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;External Supabase&lt;/strong&gt; - you bring your own Supabase project and manage it yourself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once Cloud mode is enabled, it completely overrides any external connections even if you&#39;d explicitly configured them first. This isn&#39;t documented clearly in the Lovable UI - you just get a toggle that seems like it&#39;s enabling some hosting feature, not fundamentally changing how the entire project works.&lt;/p&gt;

&lt;p&gt;The fix: delete the project entirely and start again with explicit &quot;DO NOT enable Cloud&quot; instructions from the beginning. Not ideal, but it worked.&lt;/p&gt;



&lt;h3&gt;Understanding the pieces&lt;/h3&gt;

&lt;p&gt;Once we&#39;d learned our lesson about Lovable Cloud, we needed to properly understand how environment separation actually works with this stack.&lt;/p&gt;

&lt;p&gt;The key insight came from an article by someone who&#39;d already solved this problem: &lt;a href=&quot;https://www.productcompass.pm/p/lovable-branching&quot; target=&quot;_blank&quot;&gt;Lovable Branching&lt;/a&gt;. Their approach was straightforward:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;DEV:&lt;/strong&gt; Lovable project → &lt;samp&gt;dev&lt;/samp&gt; GitHub branch → DEV Supabase instance → Lovable hosting&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;PROD:&lt;/strong&gt; Same repo → &lt;samp&gt;main&lt;/samp&gt; GitHub branch → PROD Supabase instance → Netlify hosting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The critical bit: completely separate Supabase instances. Not Supabase&#39;s branching feature (which exists but is more for preview environments), actual separate projects. One for development, one for production, zero overlap.&lt;/p&gt;

&lt;p&gt;This makes sense when you think about it. Database migrations aren&#39;t like code - you can&#39;t just merge them and hope for the best. You need to test them in isolation before running them against production data. Separate instances means you can experiment freely in dev without any risk of accidentally breaking prod.&lt;/p&gt;

&lt;h4&gt;Environment variables in three different contexts&lt;/h4&gt;

&lt;p&gt;Where things get interesting is environment variables. Turns out there are three completely different systems at play:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Frontend variables (Vite):&lt;/strong&gt; These need the &lt;samp&gt;VITE_&lt;/samp&gt; prefix to be accessible in browser-side code. You access them via &lt;samp&gt;import.meta.env.VITE_SUPABASE_URL&lt;/samp&gt; and similar. The &lt;samp&gt;.env&lt;/samp&gt; file contains your DEV values as defaults, but Netlify&#39;s environment variables override these at build time for production. This inheritance is standard Vite behaviour - actual environment variables take precedence over &lt;samp&gt;.env&lt;/samp&gt; file values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edge function variables (Deno):&lt;/strong&gt; These are managed through Supabase&#39;s &quot;Edge Function Secrets&quot; system. You set them via &lt;samp&gt;supabase secrets set KEY=value&lt;/samp&gt; or through the Supabase dashboard, and they&#39;re accessed in code via &lt;samp&gt;Deno.env.get(&#39;KEY&#39;)&lt;/samp&gt;. Here&#39;s the odd bit: Supabase treats &lt;em&gt;all&lt;/em&gt; edge function environment variables as &quot;secrets&quot; regardless of whether they&#39;re actually sensitive. Non-secret configuration like API hostnames still goes through the secrets mechanism. It&#39;s just how Supabase works. This triggered my &quot;someone is wrong on the internet&quot; inclinations, and I found (well: OK, Claudia found it whilst diagnosing the issue) a GitHub issue about it: &lt;a href=&quot;https://github.com/orgs/supabase/discussions/33636&quot; target=&quot;_blank&quot;&gt;Upload non-secret environment variables to Edge Function Secrets Management&lt;/a&gt;. Upvoted. I notice now that someone at Supabase has noted said issue, shortly after I nudged it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CLI configuration (Supabase tooling):&lt;/strong&gt; When you run &lt;samp&gt;npx supabase link --project-ref &amp;lt;PROJECT_ID&amp;gt;&lt;/samp&gt;, it writes the project reference to &lt;samp&gt;supabase/.temp/project-ref&lt;/samp&gt;. This is local state that determines which Supabase instance the CLI commands operate against. The &lt;samp&gt;.temp&lt;/samp&gt; directory is gitignored, so each developer (and each environment) can link to different projects without conflicts.&lt;/p&gt;

&lt;p&gt;The important realisation: these three systems don&#39;t talk to each other. Your frontend env vars in &lt;samp&gt;.env&lt;/samp&gt; are separate from your edge function secrets in Supabase, which are separate from your local CLI link state. They all happen to reference the same Supabase projects, but through completely independent configuration mechanisms.&lt;/p&gt;



&lt;h3&gt;The VITE_SUPABASE_PROJECT_ID battle&lt;/h3&gt;

&lt;p&gt;This is where things got properly frustrating.&lt;/p&gt;

&lt;p&gt;The Supabase client needs two things to initialise: the project URL and the publishable key. That&#39;s it. The project ID is already embedded in the URL - &lt;samp&gt;https://twvzqadjueqejcsrtaed.supabase.co&lt;/samp&gt; contains the ID right there in the subdomain. Having a separate &lt;samp&gt;VITE_SUPABASE_PROJECT_ID&lt;/samp&gt; variable is completely redundant.&lt;/p&gt;

&lt;p&gt;So we asked Lovable to remove it from the &lt;samp&gt;.env&lt;/samp&gt; file.&lt;/p&gt;

&lt;p&gt;Every. Single. Commit. It put it back.&lt;/p&gt;

&lt;p&gt;We tried being explicit: &quot;We don&#39;t use &lt;samp&gt;VITE_SUPABASE_PROJECT_ID&lt;/samp&gt;, please remove it&quot;. Lovable responded &quot;Yes, done&quot; and left it there. We manually deleted it and pushed the change ourselves. The next commit from Lovable put it back. We explained &lt;em&gt;why&lt;/em&gt; it was redundant. Lovable agreed with the reasoning, confirmed it would remove the variable, and then didn&#39;t.&lt;/p&gt;

&lt;p&gt;The AI clearly didn&#39;t understand why it kept adding this variable back. It wasn&#39;t being defiant - it genuinely seemed to think it was helping. But no amount of prompting, explaining, or manual removal could break the pattern.&lt;/p&gt;

&lt;p&gt;Claudia (my AI pair programmer, who was observing this farce) found it hilarious. I found it less hilarious. In the end, I did something rare: I surrendered. The variable is still in the codebase. It doesn&#39;t do anything, the Supabase client doesn&#39;t use it, but it&#39;s there. Lovable won.&lt;/p&gt;

&lt;p&gt;This became a useful lesson about AI code generation tools: they&#39;re brilliant at generating the initial 80% of a solution, but that last 20% - the refinement, the cleanup, the removal of unnecessary cruft - sometimes requires human intervention that the AI just can&#39;t process. Even when it claims to understand.&lt;/p&gt;



&lt;h3&gt;The review workflow&lt;/h3&gt;

&lt;p&gt;Speaking of that 80/20 split, we developed a proper review process for Lovable-generated code. This wasn&#39;t just paranoia - AI-generated code needs human oversight, especially when it&#39;s going to production.&lt;/p&gt;

&lt;p&gt;The workflow went like this:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Lovable generates code based on a prompt from the Product Owner&lt;/li&gt;
  &lt;li&gt;I creates a pull request from the feature branch to &lt;samp&gt;dev&lt;/samp&gt;&lt;/li&gt;
  &lt;li&gt;GitHub Copilot does an automated review, catching obvious issues&lt;/li&gt;
  &lt;li&gt;I review the code manually, looking for security concerns, deployment gotchas, architectural problems&lt;/li&gt;
  &lt;li&gt;Claudia reviews it as well, often catching things I missed&lt;/li&gt;
  &lt;li&gt;We compile a comprehensive list of issues and create a fix prompt for Lovable&lt;/li&gt;
  &lt;li&gt;Lovable makes another pass, addressing the feedback&lt;/li&gt;
  &lt;li&gt;Repeat until the code is actually mergeable&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This multi-layer review caught things that no single reviewer would spot. Copilot is good at identifying code smells and standard issues. I&#39;m good at spotting deployment risks and security problems. Claudia is good at catching logical inconsistencies and suggesting better patterns.&lt;/p&gt;

&lt;p&gt;And, full disclosure: I can &lt;em&gt;read&lt;/em&gt; TypeScript and React code, and having spent a few solid weeks doing &quot;self-teaching&quot; on both - I should blog this actually - I understand what&#39;s going on for the most part, but I am not a TS/React dev. I need Claudia and Copilot to review this stuff.&lt;/p&gt;

&lt;p&gt;One recurring annoyance: GitHub Copilot&#39;s automated review insists on suggesting American spellings. &quot;Initialize&quot; instead of &quot;initialise&quot;, &quot;color&quot; instead of &quot;colour&quot;. Every. Single. Review. I&#39;m a Kiwi, and a civilised person, and this is an app for a UK audience: the codebase uses British English not that tariff-addled colonial shite, but Copilot is having none of it.&lt;/p&gt;

&lt;p&gt;The key insight here is that AI code generation isn&#39;t &quot;press button, receive working code&quot;. It&#39;s more like working with a very knowledgeable but inexperienced junior developer who needs guidance on architecture, security, and project-specific patterns. The review process is where the actual quality control happens.&lt;/p&gt;



&lt;h3&gt;The actual working solution&lt;/h3&gt;

&lt;p&gt;After all the false starts and battles with Lovable&#39;s helpful tendencies, here&#39;s what actually works.&lt;/p&gt;

&lt;h4&gt;The architecture&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;DEV environment:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Supabase project: the original instance we&#39;d been using all along&lt;/li&gt;
  &lt;li&gt;GitHub branches: &lt;samp&gt;DEV&lt;/samp&gt; for integration, and each ticket&#39;s work is done in a short-lived feature branch of DEV, eg JRA-1234_remove_project_id_AGAIN&lt;/li&gt;
  &lt;li&gt;Hosting: Lovable&#39;s built-in preview hosting (good enough for dev work)&lt;/li&gt;
  &lt;li&gt;Database: DEV Supabase instance with all our test data and experimental migrations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;PROD environment:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Supabase project: fresh instance created specifically for production&lt;/li&gt;
  &lt;li&gt;GitHub branch: &lt;samp&gt;main&lt;/samp&gt; only&lt;/li&gt;
  &lt;li&gt;Hosting: Netlify (automatic deployment on push to main)&lt;/li&gt;
  &lt;li&gt;Database: PROD Supabase instance with clean migration history&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The decision to keep the original instance as DEV rather than promoting it to PROD was deliberate. The existing instance had all our development history, test data, and the occasional experimental schema change. Starting PROD fresh from a clean set of migrations gave us a proper foundation without any cruft.&lt;/p&gt;

&lt;h4&gt;The deployment process&lt;/h4&gt;

&lt;p&gt;Frontend deployment happens automatically via Netlify. When code merges to &lt;samp&gt;main&lt;/samp&gt;, Netlify detects the change, runs &lt;samp&gt;npm ci&lt;/samp&gt; followed by &lt;samp&gt;npm run build&lt;/samp&gt;, and serves the static files from the &lt;samp&gt;dist&lt;/samp&gt; folder. Environment variables configured in Netlify&#39;s UI override the &lt;samp&gt;.env&lt;/samp&gt; file defaults, giving us PROD Supabase credentials without changing any code.&lt;/p&gt;

&lt;p&gt;Backend deployment is deliberately manual. No automation, no automatic deploys on merge, no clever CI/CD pipelines. When we&#39;re ready to deploy database changes to production:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;npx supabase login
npx supabase link --project-ref &amp;lt;PROD_PROJECT_ID&amp;gt;
npx supabase db push
npx supabase functions deploy&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;That&#39;s it. Four commands, run by a human, who has presumably read the migrations and understood what they&#39;re about to do. The &lt;samp&gt;db push&lt;/samp&gt; command reads all migration files from &lt;samp&gt;supabase/migrations/&lt;/samp&gt; and applies any that haven&#39;t been run yet, tracked via the &lt;samp&gt;supabase_migrations.schema_migrations&lt;/samp&gt; table.&lt;/p&gt;

&lt;p&gt;This manual approach is a deliberate choice. Database migrations can break things in ways that frontend code changes usually don&#39;t. Having a human in the loop - someone who&#39;s actually reviewed the SQL and thought about what could go wrong - provides a safety net that automated deployments don&#39;t.&lt;/p&gt;

&lt;p&gt;And, to be transparent, we are a &lt;em&gt;very&lt;/em&gt; small team, and I am a Team Lead / Developer by trade, and all this &quot;systems config shite&quot; is a) beyond me; b) of very little interest to me. I&#39;m doing it because &quot;someone has to do it&quot; (cue: sympathetic violins). I know we should have some sort of CI/CD going on, and eventually we &lt;em&gt;will&lt;/em&gt;, but we don&#39;t need it for MVP, so I&#39;m managing it manually for now. And - as per above - it&#39;s dead easy!&lt;/p&gt;

&lt;p&gt;Oh, one thing I didn&#39;t mention is this is precisely how I finishes strumming-up the new Supabase instance for prod. Obviously the new Supabase DB was empty... I just did the &lt;samp&gt;db push&lt;/samp&gt; and &lt;samp&gt;functions deploy&lt;/samp&gt; to get it up to date with dev.&lt;/p&gt;


&lt;h4&gt;Keeping branches in sync&lt;/h4&gt;

&lt;p&gt;Between tasks, we merge &lt;samp&gt;main&lt;/samp&gt; back to &lt;samp&gt;dev&lt;/samp&gt; to keep them in sync. This prevents the two branches from drifting too far apart and makes the eventual &lt;samp&gt;dev&lt;/samp&gt; → &lt;samp&gt;main&lt;/samp&gt; merges simpler. Standard Git workflow stuff, but worth stating explicitly because Lovable&#39;s documentation focuses almost entirely on the &quot;single branch, continuous deployment&quot; model.&lt;/p&gt;



&lt;h3&gt;Edge functions and secrets&lt;/h3&gt;

&lt;p&gt;Edge functions turned out to be simpler than expected, once we understood how Supabase handles them.&lt;/p&gt;

&lt;p&gt;The functions themselves live in &lt;samp&gt;supabase/functions/&lt;/samp&gt; in the same repository as everything else. They&#39;re not a separate codebase or deployment - they&#39;re just TypeScript files that get deployed via &lt;samp&gt;npx supabase functions deploy&lt;/samp&gt;. When you push changes to GitHub, Supabase doesn&#39;t automatically deploy them (unlike the frontend with Netlify). You need to explicitly run the deploy command.&lt;/p&gt;

&lt;p&gt;Environment variables for edge functions work through Supabase&#39;s &quot;Edge Function Secrets&quot; system. Some are auto-managed by Supabase itself:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;samp&gt;SUPABASE_URL&lt;/samp&gt;&lt;/li&gt;
  &lt;li&gt;&lt;samp&gt;SUPABASE_ANON_KEY&lt;/samp&gt;&lt;/li&gt;
  &lt;li&gt;&lt;samp&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/samp&gt;&lt;/li&gt;
  &lt;li&gt;&lt;samp&gt;SUPABASE_DB_URL&lt;/samp&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These automatically have the correct values for whichever Supabase instance is running the function. DEV Supabase runs the function, it gets DEV credentials. PROD Supabase runs the function, it gets PROD credentials. No configuration needed.&lt;/p&gt;

&lt;p&gt;Any other environment variables need to be set manually per environment. For our project, this included:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;samp&gt;EMAIL_API_KEY&lt;/samp&gt; - for sending emails&lt;/li&gt;
  &lt;li&gt;&lt;samp&gt;BANK_API_ACCESS_TOKEN&lt;/samp&gt; - for our bank&#39;s API integration&lt;/li&gt;
  &lt;li&gt;&lt;samp&gt;BANK_API_WEBHOOK_SECRET&lt;/samp&gt; - webhook signature verification&lt;/li&gt;
  &lt;li&gt;&lt;samp&gt;BANK_API_HOST&lt;/samp&gt; - the API hostname (different for sandbox vs production)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is worth noting: we needed &lt;samp&gt;BANK_API_HOST&lt;/samp&gt; to be &lt;samp&gt;api-test.ourbank.com&lt;/samp&gt; in DEV and &lt;samp&gt;api.ourbank.com&lt;/samp&gt; in PROD. This isn&#39;t a secret - it&#39;s just configuration. But Supabase treats all edge function environment variables as &quot;secrets&quot; regardless of whether they&#39;re actually sensitive.&lt;/p&gt;

&lt;p&gt;You set these via the Supabase dashboard (Authentication → Secrets) or via the CLI:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;npx supabase secrets set BANK_API_HOST=api.ourbank.com&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;One gotcha: you can&#39;t view the raw values of secrets in the dashboard after they&#39;re set, only their hash. This is annoying for non-sensitive configuration values where you might want to verify what&#39;s actually configured. But it&#39;s a one-time setup per environment, so not a huge problem in practice. And there &lt;em&gt;is&lt;/em&gt; that GitHub issue&amp;hellip;&lt;/p&gt;

&lt;p&gt;This is then used in our edge function along these lines:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;function getOurBankApiUrl(): string {
  const apiHost = Deno.env.get(&#39;BANK_API_HOST&#39;);
  if (!apiHost) {
    throw new Error(
      &#39;Missing bank configuration. &#39; +
      &#39;Set BANK_API_HOST environment variable.&#39;
    );
  }
  return `https://${apiHost}/some_slug_here`;
}
&lt;/code&gt;&lt;/pre&gt;


&lt;h4&gt;JWT verification settings&lt;/h4&gt;

&lt;p&gt;Edge functions by default require valid Supabase JWTs in the &lt;samp&gt;Authorization&lt;/samp&gt; header. For webhooks (or other computer-to-computer calls) or public endpoints, you need to disable this. This goes in &lt;samp&gt;supabase/config.toml&lt;/samp&gt;:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;[functions.ourbank-webhook]
verify_jwt = false

[functions.validate-bank-details]
verify_jwt = false&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This is the only thing that should be in your &lt;samp&gt;config.toml&lt;/samp&gt;. We initially had 60+ lines of local development server configuration (API ports, database settings, auth config) that Lovable had generated. All unnecessary - that configuration is for running Supabase locally, which we&#39;re not doing. The JWT settings are needed because &lt;samp&gt;npx supabase functions deploy&lt;/samp&gt; falls back to these values.&lt;/p&gt;



&lt;h3&gt;Gotchas and non-obvious behaviours&lt;/h3&gt;

&lt;p&gt;Here&#39;s everything that wasn&#39;t obvious from the documentation, discovered through trial and error.&lt;/p&gt;

&lt;h4&gt;Supabase CLI tool location changed&lt;/h4&gt;

&lt;p&gt;Older documentation references a &lt;samp&gt;.supabase/&lt;/samp&gt; directory for CLI state. The CLI now uses &lt;samp&gt;supabase/.temp/&lt;/samp&gt; instead. When you run &lt;samp&gt;npx supabase link&lt;/samp&gt;, it writes the project reference to &lt;samp&gt;supabase/.temp/project-ref&lt;/samp&gt;, along with version tracking files and connection details.&lt;/p&gt;

&lt;p&gt;This directory must be in &lt;samp&gt;.gitignore&lt;/samp&gt; because it&#39;s environment-specific. Each developer links to their own preferred project (DEV or PROD), and these link states are stored locally. The directory structure looks like:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;supabase/
├── .temp/
│   ├── project-ref          # Linked project ID
│   ├── storage-version      # Version tracking
│   ├── rest-version
│   ├── gotrue-version
│   ├── postgres-version
│   ├── pooler-url          # Connection pooler URL
│   └── cli-latest          # CLI version check
├── functions/              # Edge functions
├── migrations/             # SQL migration files
└── config.toml            # JWT settings only&lt;/code&gt;&lt;/pre&gt;

&lt;h4&gt;The &quot;PRODUCTION&quot; badge means nothing&lt;/h4&gt;

&lt;p&gt;Every Supabase project shows a &quot;PRODUCTION&quot; badge in the dashboard header. This isn&#39;t an indicator of whether your project is actually being used for production - it&#39;s Supabase&#39;s terminology for distinguishing standalone projects from preview branches created via their branching feature. Your DEV instance will show &quot;PRODUCTION&quot; just like your actual production instance. Ignore it.&lt;/p&gt;

&lt;h4&gt;npx is not npm install -g&lt;/h4&gt;

&lt;p&gt;This is just me not being a Node dev.&lt;p&gt;

&lt;p&gt;Running &lt;samp&gt;npm install -g supabase&lt;/samp&gt; returns a warning: &quot;Installing Supabase CLI as a global module is not supported.&quot; Instead, use &lt;samp&gt;npx supabase &amp;lt;command&amp;gt;&lt;/samp&gt; for everything. This downloads the CLI on-demand, caches it in npm&#39;s global cache, and executes it. It&#39;s not &quot;installed&quot; in the traditional sense, but it works identically from a user perspective.&lt;/p&gt;

&lt;p&gt;First-time use requires &lt;samp&gt;npx supabase login&lt;/samp&gt; which opens a browser for OAuth authentication. This stores an access token locally. Without this, CLI commands fail with &quot;Access token not provided&quot;.&lt;/p&gt;

&lt;h4&gt;Netlify build doesn&#39;t touch the database&lt;/h4&gt;

&lt;p&gt;Common confusion: when Netlify runs &lt;samp&gt;npm run build&lt;/samp&gt;, it only compiles frontend code to static files. It does not run database migrations. Those are a completely separate manual step via &lt;samp&gt;npx supabase db push&lt;/samp&gt;.&lt;/p&gt;

&lt;p&gt;This separation is deliberate - frontend and backend deploy independently. You can deploy backend changes without touching the frontend, and vice versa. The deployment order matters though: always deploy backend schema changes first, then frontend code that depends on those changes.&lt;/p&gt;


&lt;h4&gt;React Router and direct navigation&lt;/h4&gt;

&lt;p&gt;Our first production bug was a proper head-scratcher. Direct navigation to &lt;samp&gt;https://ourapp.netlify.app/products&lt;/samp&gt; returned a 404, but clicking through from the home page worked fine.&lt;/p&gt;

&lt;p&gt;Turns out this is a fundamental disconnect (for me!) between how React Router works and what servers expect. React apps are Single Page Applications - there&#39;s literally one HTML file (&lt;samp&gt;index.html&lt;/samp&gt;). React Router handles navigation by swapping components in JavaScript, updating the browser&#39;s address bar without making server requests.&lt;/p&gt;

&lt;p&gt;When you click a link inside the app (like from &lt;samp&gt;/&lt;/samp&gt; to &lt;samp&gt;/products&lt;/samp&gt;), React Router intercepts it and just changes what&#39;s rendered. No server request happens. But when you directly navigate to &lt;samp&gt;/products&lt;/samp&gt; in a fresh browser tab, Netlify&#39;s server receives a request for &lt;samp&gt;/products&lt;/samp&gt;, looks for a file called &lt;samp&gt;products.html&lt;/samp&gt;, can&#39;t find it, and returns 404.&lt;/p&gt;

&lt;p&gt;I&#39;ll be honest - this felt like broken behaviour to me. Surely &quot;being able to navigate to any page directly&quot; is web application table stakes? How is this not just working? But the issue is that React and React Router are client-side libraries. They have no control over what the server does. The server needs explicit configuration to serve &lt;samp&gt;index.html&lt;/samp&gt; for all routes. I felt a bit thick when Claudia explained this to me using small words.&lt;/p&gt;

&lt;p&gt;The fix is simple: create &lt;samp&gt;public/_redirects&lt;/samp&gt; with one line:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;/* /index.html 200&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This tells Netlify: for any URL path, serve &lt;samp&gt;index.html&lt;/samp&gt; instead of looking for specific files. The &lt;samp&gt;200&lt;/samp&gt; status code means it&#39;s a rewrite, not a redirect - the browser URL stays as &lt;samp&gt;/products&lt;/samp&gt; but Netlify serves &lt;samp&gt;index.html&lt;/samp&gt; behind the scenes. React boots up, React Router sees the URL, and renders the correct page.&lt;/p&gt;

&lt;p&gt;Why didn&#39;t we hit this during development? Because Vite (the dev server) already has this behaviour built in. It knows you&#39;re building an SPA and handles it automatically. The problem only appears when you deploy to a production server that doesn&#39;t know about your client-side routing.&lt;/p&gt;

&lt;p&gt;This should probably be included by default in any SPA scaffolding tool, but it&#39;s not. Add it to your &quot;first deploy checklist&quot; and move on.&lt;/p&gt;



&lt;h4&gt;Environment variable override precedence&lt;/h4&gt;

&lt;p&gt;Vite&#39;s environment variable resolution: actual environment variables (set in Netlify&#39;s UI) override &lt;samp&gt;.env&lt;/samp&gt; file values at build time. The &lt;samp&gt;.env&lt;/samp&gt; file serves as the fallback for local development. This means you can commit DEV credentials in &lt;samp&gt;.env&lt;/samp&gt;, and Netlify will use PROD credentials from its configuration without any code changes.&lt;/p&gt;

&lt;h4&gt;Migration file format matters&lt;/h4&gt;

&lt;p&gt;Supabase requires migration files to use timestamp format: &lt;samp&gt;YYYYMMDDHHMMSS_description.sql&lt;/samp&gt;. Lovable doesn&#39;t use this format by default. You need to explicitly instruct it in your Lovable Knowledge file, and even then it sometimes needs reinforcement in prompts. We added this to our Knowledge file:&lt;/p&gt;

&lt;blockquote&gt;
Database migrations use timestamp format: YYYYMMDDHHMMSS_description.sql&lt;br&gt;
Create a migration file for EVERY database change (tables, columns, indexes, constraints, RLS policies, functions, triggers)&lt;br&gt;
Never make database changes without generating a corresponding migration file
&lt;/blockquote&gt;

&lt;p&gt;Even with this, Lovable occasionally forgets, or will use a GUID in place of the description. Code review catches it.&lt;/p&gt;

&lt;h4&gt;Supabase key format evolution&lt;/h4&gt;

&lt;p&gt;Supabase has two key formats in the wild:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Legacy anon public key (JWT format): &lt;samp&gt;rlWuoTpv...&lt;/samp&gt;&lt;/li&gt;
  &lt;li&gt;New publishable key format: &lt;samp&gt;sb_publishable_...&lt;/samp&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both work, but the dashboard now recommends using publishable keys. We&#39;re using the legacy JWT format for now because both our DEV and PROD instances started with it, and mixing formats between environments seemed like asking for trouble. Migration to the new format is a separate ticket for when we&#39;re not in the middle of setting up production.&lt;/p&gt;




&lt;h3&gt;What we ended up with&lt;/h3&gt;

&lt;p&gt;After all the false starts, surrenders to Lovable&#39;s stubbornness, and discoveries about how these tools actually work, we&#39;ve got a functioning dev/prod separation that&#39;s simple enough to be maintainable.&lt;/p&gt;

&lt;p&gt;The key decisions that made it work:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Separate Supabase instances rather than using branching features - complete isolation, no data leakage risk&lt;/li&gt;
  &lt;li&gt;Manual database deployments rather than automation - deliberate, reviewed, controlled&lt;/li&gt;
  &lt;li&gt;Short-lived feature branches off a long-lived dev branch - standard Git workflow that the team already understands&lt;/li&gt;
  &lt;li&gt;Netlify for frontend hosting with environment variable overrides - zero-config deployment that just works&lt;/li&gt;
  &lt;li&gt;Multi-layer code review process - Copilot for automated checks, me for architecture and security, Claudia for catching the bits I miss&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow is straightforward: develop in Lovable against DEV, review the pull request, merge to dev, smoke test, merge dev to main when ready, manually deploy backend changes, let Netlify handle the frontend. It&#39;s not fancy, there&#39;s no sophisticated CI/CD pipeline, but it&#39;s appropriate for a small team building an MVP.&lt;/p&gt;

&lt;p&gt;The biggest lesson: AI code generation tools like Lovable are brilliant at the initial 80% of implementation, but that last 20% - the refinement, security review, deployment considerations - still needs human oversight. A &lt;em&gt;technically proficient&lt;/em&gt; human. The review workflow isn&#39;t overhead; it&#39;s where the actual quality control happens.&lt;/p&gt;

&lt;p&gt;Don&#39;t get sucked into the hype: &quot;vibe coding&quot; is simply not a thing when it comes to production applications. It&#39;s only good for building functional demos.&lt;/p&gt;

&lt;p&gt;And sometimes, you just have to accept that &lt;samp&gt;VITE_SUPABASE_PROJECT_ID&lt;/samp&gt; is going to live in your codebase forever, doing absolutely nothing, because Lovable has decided it belongs there and no amount of reasoning will change its mind.&lt;/p&gt;

&lt;p&gt;Righto.&lt;/p&gt;

&lt;p&gt;-- &lt;br&gt;Adam&lt;/p&gt;

&lt;hr&gt;

&lt;p&gt;P.S. As well as doing all the Netlify config for PROD, I also set up a separate Netlify site for TEST. This one triggers builds off merges to &lt;samp&gt;dev&lt;/samp&gt; and uses the DEV Supabase credentials. It&#39;s exposed to the world on the &lt;samp&gt;admin-test&lt;/samp&gt; subdomain (live is just &lt;samp&gt;admin&lt;/samp&gt;). This gives the Product Owner a stable environment to test new features before they go live, running in the same hosting setup as production but against the dev database. Means we can catch UI issues or integration problems in a production-like environment without risking actual production.&lt;/p&gt;



</content><link rel='edit' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/3252238250263397413'/><link rel='self' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/3252238250263397413'/><link rel='alternate' type='text/html' href='https://blog.adamcameron.me/2026/01/setting-up-devprod-environments-with.html' title='Setting up dev/prod environments with Lovable and Supabase'/><author><name>Adam Cameron</name><uri>http://www.blogger.com/profile/04830762402027484810</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author></entry><entry><id>tag:blogger.com,1999:blog-8141574561530432909.post-1099445494115877345</id><published>2025-12-12T13:58:00.001+00:00</published><updated>2025-12-12T14:39:02.609+00:00</updated><title type='text'>Lovable and its seemingly self-defeating pricing and business model</title><content type='html'>&lt;p&gt;G&#39;day:&lt;/p&gt;

&lt;p&gt;Sigh. I watched a video about &lt;a href=&quot;https://lovable.dev/&quot; target=&quot;_blank&quot;&gt;Lovable&lt;/a&gt; the other day, which in and of itself was entirely adequate, and provided some interesting information in that way that one paragraph of text could also provide (such is the way with YT technical videos sometimes). It&#39;s this one: &lt;a href=&quot;https://www.youtube.com/watch?v=mOak_imYmqU&amp;lc=Ugy_aN5FpVjoJbNiyHp4AaABAg.AP51M3HrI02AQSz8zankPT&quot; target=&quot;_blank&quot;&gt;Master Lovable AI in 20 Minutes (NEW 2.0 UPDATE)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One of the tactics the author detailed was this thing in Lovable that - despite it being an AI-driven app generator thingey (you know &quot;vibe coding&quot; etc), the standard approach to things seems to be to use &lt;em&gt;someone else&#39;s&lt;/em&gt; AI tooling to do most of the planning / AI / back-and-forth work, and then get - eg - ChatGPT to summarise the plan as a &quot;prompt&quot; which one then gives to the Lovable AI, and it churns away and will generate a prototype-quality app that follows most of the guidance, but still grafts on a sixth finger (sic) here and there because well: AI. At the time I was bemused as to why one would use another AI tool to do the planning, instead of using Lovable itself.&lt;/p&gt;

&lt;p&gt;So I asked the question in the video comments:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;a href=&quot;https://www.youtube.com/@darrelwilson&quot; target=&quot;_blank&quot;&gt;@darrelwilson&lt;/a&gt; I was kinda perplexed as to why it was necessary to use one AI to draft guidance for another AI? I realise this ecosystem is fast moving, but looking today Lovable is using Opus 4.5, which should be on a &quot;cognitive&quot; par with ChatGPT&#39;s LLM... so what&#39;s the difference between prompting ChatGPT as an intermediary over just prompting Lovable in the first place? Is this mostly to game token-usage on Lovable? Or is it to keep Lovable&#39;s memory-context tight &amp;amp; not cluttered with intermediary discussions with ChatGPT whilst drafting the &quot;ideal&quot; prompt? Keeping zeitgeisty, I asked Claude Desktop&#39;s Opus 4.5 why one might do this, and its reaction was bemusement, as it seems like an unnecessary step..?
&lt;/blockquote&gt;

&lt;p&gt;Someone replied:&lt;/p&gt;


&lt;blockquote&gt;@adamcameron1  I mean you should really read the prompt GPT provides. If you put a generic prompt into Lovable directly youll have less control &amp; say into what it builds. Its going to extroplate if you aren&#39;t specific.
&lt;/blockquote&gt;  
  
&lt;p&gt;That didn&#39;t quite land with me, so I followed-up:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hi @shenshaw5345, I appreciate the response, cheers!&lt;/p&gt;

&lt;p&gt;Reasoning about this, I&#39;d expect one would not charge in and go &quot;build an app! Oh... no, hang on... not like that. Do it this way... damn, still not right... oh maaaan, this is never gonna work&quot;; one would do precisely what was done in this example via ChatGPT first, and &quot;discuss&quot; with Lovable what needs building before any code is written. Once one reckons everyone is on the same page with the plan, get Lovable to spit out the plan, review (poss rinse and repeat), and then go &quot;OK build that&quot;. Not all chat prompts need to result in code being written, right?&lt;/p&gt;

&lt;p&gt;NB: I&#39;m only evaluating the possibilities of Lovable as yet - this is part of the evaluation - so I am, admittedly, speculating (based on ChatGPT, GitHub Copilot and claude.ai all working this way). Then again, I also know that one has to be direct with AI IDE integrations and prompt it &quot;we are currently just planning, do not write any code until we agree on a plan and I tell you to crack on with it&quot;. I&#39;m presuming Lovable works the same, given it&#39;s the same underlying AI models...&lt;/p&gt;

&lt;p&gt;THAT SAID, I just checked Lovable&#39;s pricing, and it&#39;s *ludicrous*... 100 tokens for $25 for a MONTH (as an example; there are better price points than that, obvs).  One could burn most of those tokens in a planning session for one app component! Compare that to Claude.ai which measures its tokens by the million for similar amounts of $$$. Obvs not directly analogous, but honestly, Lovable seems to have missed a trick here: it&#39;s important to have the chat as part of the context for the work an agent does. A lot of that is being lost to ChatGPT&#39;s context in this case. And a summary of a chat from one AI to another is not the same as the nuance of the actual chat.&lt;/p&gt;

&lt;p&gt;This, however, explains why one ought not plan using Lovable. They&#39;ve hamstrung their offering with their pricing. Duly noted.&lt;/p&gt;

&lt;p&gt;Thanks again!&lt;/p&gt;
&lt;/blockquote&gt;  

&lt;p&gt;And someone else came back to me:&lt;/p&gt;

&lt;blockquote&gt;
 @adamcameron1  you might not have an idea fully flushed out in your mind.  You braindump into chatgpt and it will organize a precise starter prompt for lovable to start.  Then u iterate off that.  In the AI world, you want to have to starting point as close as you can get it to the finish line to make things easier.  5 or 10 edits is ok.  Try doing 50-100 edits and then you start to run into context window issues….. at least that how I assume lovable works.. just started lovable a couple days ago, but i have a good understanding on how prompting affects LLMs
&lt;/blockquote&gt;  


&lt;p&gt;This respondent didn&#39;t quite get what I was meaning, so I followed up. And this leads to the reason I am reposting this here:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hi @youcanfindrace. It might be that. But given I&#39;ve been working almost exclusively via various AI tooling for the bulk of the year this year; and for about 50% of my work time last year, I&#39;m not so sure I&#39;m misunderstanding how it all comes together. But it&#39;s possible, sure.&lt;/p&gt;

&lt;p&gt;What I rather more think is that I wasn&#39;t as clear as I could have been previously, and my point didn&#39;t land with you. Let me change a few things around, and try to be clearer.&lt;/p&gt;

&lt;p&gt;Let&#39;s pretend the guidance given wasn&#39;t &quot;use ChatGPT to build a prompt&quot;, but instead &quot;use Claude.ai to build a prompt&quot;. You can hopefully see how &quot;using ChatGPT&quot; and &quot;using Claude&quot; are - for most intents - analogous processes: different tools, same job. Like using a DeWalt drill or a Ryobi drill to drill a hole: different sorts of drills, but they&#39;re still drills. So we use Claude to build a lovely fully-realised prompt, which we then paste into Lovable&#39;s UI... &lt;em&gt;which then uses the same AI as Claude uses to analyse the prompt and perform that action&lt;/em&gt; Lovable uses Anthropic&#39;s LLMs under the hood (so... same as Claude... same thing... different wrapper, by a different vendor). But even if it&#39;s not a wrapper around the same tool, it&#39;s still an &lt;em&gt;analogous&lt;/em&gt; tool (Lovable=&gt;DeWalt; Claude=&gt;Ryobi) in and of itself. &quot;use a chat with ChatGPT to build a prompt&quot;, &quot;use a chat with Claude to build a prompt&quot; and &quot;use a chat with Lovable to build a prompt&quot; are analogous exercises. EXCEPT... that Lovable simply don&#39;t facilitate this, because their pricing for their usage tokens are orders of magnitude more than other AI vendors, making it prohibitive to use its AI for... &lt;em&gt;actual AI stuff&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&quot;Building a prompt&quot; is only a tool to transfer the summary of an AI&#39;s reasoning between tooling that is otherwise disconnected. For various reasons I sometimes need to have a similar chat with Claude and ChatGPT (or Gemini), and the way to &quot;get the other AI up to speed&quot;, is to ask the initial AI &quot;can you please summarise this so I can give it to Gemini and see what they think&quot;. One only builds a prompt when doing this context-interchange. However these summaries always lose context and nuance from the underlying chat (think Cliff&#39;s Notes vs the actual novel the notes are summarising). In application building, that context/nuance is &lt;em&gt;really important&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Given Lovable uses Anthropic&#39;s LLMs, the process in a well-realised application would be to use the &lt;strong&gt;Chat&lt;/strong&gt; interface &lt;em&gt;in Lovable&lt;/em&gt; to build the instructions for the &lt;strong&gt;Agent&lt;/strong&gt; interface (still Lovable, different part of the same tool) to then go do the code generation - though even that&#39;s more ceremony than necessary. Ideally you&#39;d just have the natural back-and-forth in one interface without artificial handoffs between different AI tools.&lt;/p&gt;

&lt;p&gt;However I have done further research into this, and have identified the reason for the way it is (well: others have, I&#39;m just parroting it here). It&#39;s simply a bad business model on Lovable&#39;s part, basically. They&#39;re approximately paying retail rates to purchase usage tokens from Anthropic (so: same if you or I have a plan with them to use Claude.ai), and instead of doing something reasonable like tack a margin on to that cost, they present their &quot;credits&quot; as some sort of priceless artifact that can be only used sparingly, and you should be thankful to have any at all; whereas if one was using Claude etc, then they&#39;re basically given away. A chat in Claude is allocated 190k tokens. 190000. And - as an example - I&#39;ve been chatting back and forth for a business day now, and have used 35k in the current chat. And what happens when I use the 190k? It tells me to start another chat. I&#39;m on a $50/month plan I think, and I&#39;ve never run into a wall with token usage. And these are &lt;em&gt;the same tokens&lt;/em&gt; that Lovable are using under the hood. There&#39;s not a one-to-one measure between what Anthropic terms as a token and what Lovable terms a credit, but the disparity is orders of magnitude. Lovable have priced themselves out of the market for their users to be able to use their tool as it ought to be intended.&lt;/p&gt;

&lt;p&gt;That&#39;s why the ChatGPT-as-intermediary workflow exists.&lt;/p&gt; 
&lt;/blockquote&gt;  

&lt;p&gt;And the comment was promptly deleted. I dunno if it was by YT&#39;s bots, or by the bloke who did the video: there was no explanantion. I changed a few things (in case I was being a meany without noticing), and it was... deleted again. I finally posted a kinda &quot;shrug&quot; response, in the hope that the person I was trying to reply to would see that I did value their feedback to me:&lt;/p&gt;

&lt;blockquote&gt;
@youcanfindrace  Not entirely sure why my reply to you keeps getting deleted: there was nothing code-of-conduct-tripping about it. Poss cos I said something that was not completely in agreement about how wonderful Lovable is, based on research I did into its pricing. But I answered my own question in the process anyhow, so... job done. Soz I couldn&#39;t post it here. [eyeroll].  
&lt;/blockquote&gt;

&lt;p&gt;At time of writing, that reply is still there.&lt;/p&gt;

&lt;p&gt;Anyhoo, I&#39;m not gonna take the time to do that research (largely googling, reading reddit / stackoverflow, and asking Claude and Gemini) and writing it up only to have it deleted. So I&#39;m reproducing it here where I own it.&lt;/p&gt;

&lt;p&gt;And that&#39;s that.&lt;/p&gt;

&lt;p&gt;Righto.&lt;/p&gt;

&lt;p&gt;-- &lt;br&gt;Adam&lt;/p&gt;

</content><link rel='edit' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/1099445494115877345'/><link rel='self' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/1099445494115877345'/><link rel='alternate' type='text/html' href='https://blog.adamcameron.me/2025/12/lovable-and-its-seemingly-self.html' title='Lovable and its seemingly self-defeating pricing and business model'/><author><name>Adam Cameron</name><uri>http://www.blogger.com/profile/04830762402027484810</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author></entry><entry><id>tag:blogger.com,1999:blog-8141574561530432909.post-1344505881823166862</id><published>2025-10-10T19:05:00.002+00:00</published><updated>2025-10-10T19:12:47.346+00:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Javascript"/><category scheme="http://www.blogger.com/atom/ns#" term="TypeScript"/><title type='text'>TypeScript: any, unknown, never</title><content type='html'>&lt;p&gt;G&#39;day:&lt;/p&gt;

&lt;p&gt;I&#39;ve been using &lt;samp&gt;any&lt;/samp&gt; and &lt;samp&gt;unknown&lt;/samp&gt; in TypeScript for a while now - enough to know that ESLint hates one and tolerates the other, and that sometimes you need to do &lt;samp&gt;x as unknown as y&lt;/samp&gt; to make the compiler shut up. But knowing they exist and actually understanding what they&#39;re for are different things.&lt;/p&gt;

&lt;p&gt;The Jira ticket I set up for this was straightforward enough:&lt;/p&gt;

&lt;blockquote&gt;Both &lt;samp&gt;unknown&lt;/samp&gt; and &lt;samp&gt;any&lt;/samp&gt; represent values of uncertain type, but they have different safety guarantees. &lt;samp&gt;any&lt;/samp&gt; opts out of type checking entirely, while &lt;samp&gt;unknown&lt;/samp&gt; is type-safe and requires narrowing before use.&lt;/blockquote&gt;

&lt;p&gt;Simple, right? &lt;samp&gt;any&lt;/samp&gt; turns off TypeScript&#39;s safety checks, &lt;samp&gt;unknown&lt;/samp&gt; keeps them on. I built some examples, wrote some tests, and thought I was done.&lt;/p&gt;

&lt;p&gt;Then Claudia pointed out I&#39;d completely missed the point of type narrowing. I was using type &lt;em&gt;assertions&lt;/em&gt; (&lt;samp&gt;value as string&lt;/samp&gt;) instead of type &lt;em&gt;guards&lt;/em&gt; (actually checking what the value is at runtime). Assertions just tell TypeScript to trust you. Guards actually verify you&#39;re right.&lt;/p&gt;

&lt;p&gt;Turns out there&#39;s a difference between &quot;making the compiler happy&quot; and &quot;writing safe code&quot;.&lt;/p&gt;

&lt;h3&gt;any - when you genuinely don&#39;t know or don&#39;t care&lt;/h3&gt;

&lt;p&gt;I started with a generic key-value object that could hold anything:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export type WritableValueObject = Record&amp;lt;string, any&amp;gt;
export type ValueObject = Readonly&amp;lt;WritableValueObject&amp;gt;

type keyValue = [string, any]

export function toValueObject(...kv: keyValue[]): ValueObject {
  const vo: WritableValueObject = kv.reduce(
    (valueObject: WritableValueObject, kv: keyValue): ValueObject =&amp;gt; {
      valueObject[kv[0]] = kv[1]
      return valueObject
    },
    {} as WritableValueObject
  )
  return vo
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.22/src/lt-35/any.ts&quot; target=&quot;_blank&quot;&gt;any.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;ESLint immediately flags every &lt;samp&gt;any&lt;/samp&gt; with warnings about unsafe assignments and lack of type checking. But this is actually a legitimate use case - I&#39;m building a container that genuinely holds arbitrary values. The whole point is that I don&#39;t know what&#39;s in there and don&#39;t need to.&lt;/p&gt;

&lt;p&gt;The &lt;samp&gt;Readonly&amp;lt;...&amp;gt;&lt;/samp&gt; wrapper makes it immutable after creation, which is what you want for a value object. Try to modify it and TypeScript complains about the index signature being readonly. The error message says &lt;samp&gt;Readonly&amp;lt;WritableValueObject&amp;gt;&lt;/samp&gt; instead of just &lt;samp&gt;ValueObject&lt;/samp&gt; because TypeScript helpfully expands type aliases in error messages. Sometimes this is useful (showing you what the type actually is), sometimes it&#39;s just verbose.&lt;/p&gt;

&lt;h3&gt;unknown - the safer alternative that&#39;s actually more annoying&lt;/h3&gt;

&lt;p&gt;The &lt;samp&gt;unknown&lt;/samp&gt; version looks almost identical:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export type WritableValueObject = Record&amp;lt;string, unknown&amp;gt;
export type ValueObject = Readonly&amp;lt;WritableValueObject&amp;gt;

type keyValue = [string, unknown]

export function toValueObject(...kv: keyValue[]): ValueObject {
  const vo: WritableValueObject = kv.reduce(
    (valueObject: WritableValueObject, kv: keyValue): ValueObject =&amp;gt; {
      valueObject[kv[0]] = kv[1]
      return valueObject
    },
    {} as WritableValueObject
  )
  return vo
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.22/src/lt-35/unknown.ts&quot; target=&quot;_blank&quot;&gt;unknown.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The difference shows up when you try to use the values. With &lt;samp&gt;any&lt;/samp&gt;, you can do whatever you want:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const value = vo.someKey;
const reversed = reverse(value); // Works fine with any&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;With &lt;samp&gt;unknown&lt;/samp&gt;, TypeScript blocks you:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const value = vo.someKey;
const reversed = reverse(value); // Error: &#39;value&#39; is of type &#39;unknown&#39;&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;My first solution was type assertions:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const reversed = reverse(value as string); // TypeScript: &quot;OK, if you say so&quot;&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This compiles. The tests pass. I thought I was done.&lt;/p&gt;

&lt;p&gt;Then Claudia pointed out I wasn&#39;t actually &lt;em&gt;checking&lt;/em&gt; anything - I was just telling TypeScript to trust me. Type assertions are a polite way of saying &quot;shut up, compiler, I know what I&#39;m doing&quot;. Which is fine when you genuinely do know, but defeats the point of using &lt;samp&gt;unknown&lt;/samp&gt; in the first place.&lt;/p&gt;


&lt;h3&gt;Type guards - actually checking instead of just asserting&lt;/h3&gt;

&lt;p&gt;The proper way to handle &lt;samp&gt;unknown&lt;/samp&gt; is with type guards - runtime checks that prove what type you&#39;re dealing with. TypeScript then narrows the type based on those checks.&lt;/p&gt;

&lt;p&gt;The simplest is &lt;samp&gt;typeof&lt;/samp&gt;:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const theWhatNow = returnsAsUnknown(input);

if (typeof theWhatNow === &#39;string&#39;) {
  const reversed = reverse(theWhatNow); // TypeScript knows it&#39;s a string now
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.22/tests/lt-35/unknown.test.ts&quot; target=&quot;_blank&quot;&gt;unknown.test.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Inside the &lt;samp&gt;if&lt;/samp&gt; block, TypeScript knows &lt;samp&gt;theWhatNow&lt;/samp&gt; is a string because the &lt;samp&gt;typeof&lt;/samp&gt; check proved it. Outside that block, it&#39;s still &lt;samp&gt;unknown&lt;/samp&gt;.&lt;/p&gt;

&lt;p&gt;For objects, use &lt;samp&gt;instanceof&lt;/samp&gt;:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const theWhatNow = returnsAsUnknown(input);

if (theWhatNow instanceof SomeClass) {
  expect(theWhatNow.someMethod(&#39;someValue&#39;)).toEqual(&#39;someValue&#39;);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And for custom checks, you can write type guard functions with the &lt;samp&gt;is&lt;/samp&gt; predicate:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export class SomeClass {
  someMethod(someValue: unknown): unknown {
    return someValue
  }

  static isValid(value: unknown): value is SomeClass {
    return value instanceof SomeClass
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.22/src/lt-35/unknown.ts&quot; target=&quot;_blank&quot;&gt;unknown.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;samp&gt;value is SomeClass&lt;/samp&gt; return type tells TypeScript that if this function returns &lt;samp&gt;true&lt;/samp&gt;, the value is definitely a &lt;samp&gt;SomeClass&lt;/samp&gt;:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;if (SomeClass.isValid(theWhatNow)) {
  expect(theWhatNow.someMethod(&#39;someValue&#39;)).toEqual(&#39;someValue&#39;);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This is proper type safety - you&#39;re checking at runtime, not just asserting at compile time.&lt;/p&gt;

&lt;h3&gt;Error handling with unknown&lt;/h3&gt;

&lt;p&gt;The most practical use of &lt;samp&gt;unknown&lt;/samp&gt; is in error handling. Before TypeScript 4.0, everyone wrote:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;try {
  throwSomeError(&#39;This is an error&#39;)
} catch (e) {  // e is implicitly &#39;any&#39;
  console.log(e.message)  // Hope it&#39;s an Error!
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now you can (and should) use &lt;samp&gt;unknown&lt;/samp&gt;:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;try {
  throwSomeError(&#39;This is an error&#39;)
} catch (e: unknown) {
  expect(e).toBeInstanceOf(SomeError)
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.22/tests/lt-35/unknown.test.ts&quot; target=&quot;_blank&quot;&gt;unknown.test.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Catches can throw anything - not just Error objects. Someone could throw a string, a number, or literally anything. Using &lt;samp&gt;unknown&lt;/samp&gt; forces you to check what you actually caught before using it.&lt;/p&gt;

&lt;h3&gt;So which one should you use?&lt;/h3&gt;

&lt;p&gt;Here&#39;s the thing though - for my ValueObject use case, &lt;samp&gt;unknown&lt;/samp&gt; is technically safer but practically more annoying. The whole point of a generic key-value store is that you don&#39;t know what&#39;s in there. Making users narrow types every time they retrieve a value is tedious:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const value = getValueForKey(vo, &#39;someKey&#39;);
if (typeof value === &#39;string&#39;) {
  doSomething(value);
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;versus just:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const value = getValueForKey(vo, &#39;someKey&#39;);
doSomething(value as string);
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;For a genuinely generic container where you&#39;re accepting &quot;no idea what this is&quot; as part of the design, &lt;samp&gt;any&lt;/samp&gt; is the honest choice. You&#39;re not pretending to enforce safety on truly dynamic data.&lt;/p&gt;

&lt;p&gt;But for error handling, function parameters that could be anything, or situations where you&#39;ll actually check the type before using it, &lt;samp&gt;unknown&lt;/samp&gt; is the better option. It forces you to handle the uncertainty explicitly rather than hoping for the best.&lt;/p&gt;


&lt;h3&gt;never - the type that can&#39;t exist&lt;/h3&gt;

&lt;p&gt;While &lt;samp&gt;any&lt;/samp&gt; and &lt;samp&gt;unknown&lt;/samp&gt; are about values that could be anything, &lt;samp&gt;never&lt;/samp&gt; is about values that can&#39;t exist at all. It&#39;s the bottom type - nothing can be assigned to it.&lt;/p&gt;

&lt;p&gt;The most obvious use is functions that never return:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export function throwAnError(message: string): never {
  throw new Error(message)
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.22/src/lt-35/never.ts&quot; target=&quot;_blank&quot;&gt;never.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Functions that throw or loop forever return &lt;samp&gt;never&lt;/samp&gt; because they don&#39;t return at all. TypeScript uses this to detect unreachable code:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;expect(() =&amp;gt; {
  throwAnError(&#39;an error&#39;)
  // &quot;Unreachable code detected.&quot;
  const x: string = &#39;&#39;
  void x
}).toThrow(&#39;an error&#39;)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.22/tests/lt-35/never.test.ts&quot; target=&quot;_blank&quot;&gt;never.test.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;samp&gt;const x&lt;/samp&gt; line gets flagged because TypeScript knows the previous line never returns control.&lt;/p&gt;

&lt;p&gt;Things get more interesting with conditional &lt;samp&gt;never&lt;/samp&gt;:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export function throwsAnErrorIfItIsBad(message: string): boolean | never {
  if (message.toLowerCase().indexOf(&#39;bad&#39;) !== -1) {
    throw new Error(message)
  }
  return false
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The return type says &quot;returns a boolean, or never returns at all&quot;. TypeScript doesn&#39;t flag unreachable code after calling this function because it might actually return normally.&lt;/p&gt;

&lt;h3&gt;Exhaustiveness checking&lt;/h3&gt;

&lt;p&gt;The clever use of &lt;samp&gt;never&lt;/samp&gt; is exhaustiveness checking in type narrowing:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export function returnsStringsOrNumbers(
  value: string | number
): string | number {
  if (typeof value === &#39;string&#39;) {
    const valueToReturn = value + &#39;&#39;
    return valueToReturn
  }
  if (typeof value === &#39;number&#39;) {
    const valueToReturn = value * 1
    return valueToReturn
  }
  const valueToReturn = value // TypeScript hints: const valueToReturn: never
  return valueToReturn
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.22/src/lt-35/never.ts&quot; target=&quot;_blank&quot;&gt;never.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After checking for string and number, TypeScript knows that &lt;samp&gt;value&lt;/samp&gt; can&#39;t be anything else, so it infers the type as &lt;samp&gt;never&lt;/samp&gt;. This is TypeScript&#39;s way of saying &quot;we&#39;ve handled all possible cases&quot;.&lt;/p&gt;

&lt;p&gt;If you tried to call the function with something that wasn&#39;t a string or number (like an array cast to &lt;samp&gt;unknown&lt;/samp&gt; then to &lt;samp&gt;string&lt;/samp&gt;), TypeScript won&#39;t catch it at compile time because you&#39;ve lied to the compiler. But at least the &lt;samp&gt;never&lt;/samp&gt; hint shows you&#39;ve exhausted the legitimate cases.&lt;/p&gt;

&lt;h3&gt;The actual lesson&lt;/h3&gt;

&lt;p&gt;I went into this thinking I understood these types well enough - &lt;samp&gt;any&lt;/samp&gt; opts out, &lt;samp&gt;unknown&lt;/samp&gt; is safer, &lt;samp&gt;never&lt;/samp&gt; is for functions that don&#39;t return. All true, but missing the point.&lt;/p&gt;

&lt;p&gt;The real distinction is between compile-time assertions and runtime checks. Type assertions (&lt;samp&gt;as string&lt;/samp&gt;) tell TypeScript &quot;trust me&quot;, but they don&#39;t verify anything. Type guards (&lt;samp&gt;typeof&lt;/samp&gt;, &lt;samp&gt;instanceof&lt;/samp&gt;, custom predicates) actually check at runtime.&lt;/p&gt;

&lt;p&gt;For genuinely dynamic data like a generic ValueObject, &lt;samp&gt;any&lt;/samp&gt; is the honest choice - you&#39;re accepting the lack of type safety as part of the design. For cases where you&#39;ll actually verify the type before using it (like error handling), &lt;samp&gt;unknown&lt;/samp&gt; forces you to be explicit about those checks.&lt;/p&gt;

&lt;p&gt;And &lt;samp&gt;never&lt;/samp&gt; is TypeScript&#39;s way of tracking control flow and exhaustiveness, which is useful when you actually pay attention to what it&#39;s telling you.&lt;/p&gt;

&lt;p&gt;The code for all this is in the &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.22/src/lt-35&quot; target=&quot;_blank&quot;&gt;learning-typescript repository&lt;/a&gt;, with test examples showing the differences between assertions and guards. Thanks to Claudia for pointing out I was doing type assertions instead of actual type checking - turns out there&#39;s a difference between making the compiler happy and writing safe code.&lt;/p&gt;

&lt;p&gt;Righto.&lt;/p&gt;

&lt;p&gt;-- &lt;br&gt;Adam&lt;/p&gt;

</content><link rel='edit' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/1344505881823166862'/><link rel='self' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/1344505881823166862'/><link rel='alternate' type='text/html' href='https://blog.adamcameron.me/2025/10/typescript-any-unknown-never.html' title='TypeScript: any, unknown, never'/><author><name>Adam Cameron</name><uri>http://www.blogger.com/profile/04830762402027484810</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author></entry><entry><id>tag:blogger.com,1999:blog-8141574561530432909.post-2952785678071236871</id><published>2025-10-06T21:44:00.003+00:00</published><updated>2025-10-06T21:47:04.567+00:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Python"/><title type='text'>Setting up a Python learning environment: Docker, pytest, and ruff</title><content type='html'>&lt;p&gt;G&#39;day:&lt;/p&gt;

&lt;p&gt;I&#39;m learning Python. Not because I particularly want to, but because my 14-year-old son Zachary has IT homework and I should probably be able to help him with it. I&#39;ve been a web developer for decades, but Python&#39;s never been part of my stack. Time to fix that gap.&lt;/p&gt;

&lt;p&gt;This article covers getting a Python learning environment set up from scratch: Docker container with modern tooling, pytest for testing, and ruff for code quality. The goal is to have a proper development environment where I can write code, run tests, and not have things break in stupid ways. Nothing revolutionary here, but documenting it for when I inevitably forget how Python dependency management works six months from now.&lt;/p&gt;

&lt;p&gt;The repo&#39;s at &lt;a href=&quot;https://github.com/adamcameron/learning-python/tree/3.0.2&quot; target=&quot;_blank&quot;&gt;github.com/adamcameron/learning-python&lt;/a&gt; (tag 3.0.2), and I&#39;m tracking this as Jira tickets because that&#39;s how my brain works. LP-1 was the Docker setup, LP-2 was the testing and linting toolchain.&lt;/p&gt;

&lt;h3&gt;Getting Docker sorted&lt;/h3&gt;

&lt;p&gt;First job was getting a Python container running. I&#39;m not installing Python directly on my Windows machine - everything goes in Docker. This keeps the host clean and makes it easy to blow away and rebuild when something inevitably goes wrong.&lt;/p&gt;

&lt;p&gt;I went with &lt;samp&gt;uv&lt;/samp&gt; for dependency management. It&#39;s the modern Python tooling that consolidates what used to be pip, virtualenv, and a bunch of other stuff into one fast binary. It&#39;s written in Rust, so it&#39;s actually quick, and it handles the virtual environment isolation properly.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://github.com/adamcameron/learning-python/blob/3.0.2/docker/docker-compose.yml&quot; target=&quot;_blank&quot;&gt;docker-compose.yml&lt;/a&gt; is straightforward:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;services:
    python:
        build:
            context: ..
            dockerfile: docker/python/Dockerfile

        volumes:
            - ..:/usr/src/app
            - venv:/usr/src/app/.venv

        stdin_open: true
        tty: true

volumes:
    venv:
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The key bit here is that separate volume for &lt;samp&gt;.venv&lt;/samp&gt;. Without it, you get the same problem as with Node.js - the host&#39;s virtual environment conflicts with the container&#39;s. Using a named volume keeps the container&#39;s dependencies isolated while still letting me edit source files on the host.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://github.com/adamcameron/learning-python/blob/3.0.2/docker/python/Dockerfile&quot; target=&quot;_blank&quot;&gt;Dockerfile&lt;/a&gt; handles the initial setup:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;FROM astral/uv:python3.11-bookworm

RUN echo &quot;alias ll=&#39;ls -alF&#39;&quot; &gt;&gt; ~/.bashrc
RUN echo &quot;alias cls=&#39;clear; printf \&quot;\033[3J\&quot;&#39;&quot; &gt;&gt; ~/.bashrc

RUN [&quot;apt-get&quot;, &quot;update&quot;]
RUN [&quot;apt-get&quot;, &quot;install&quot;, &quot;-y&quot;, &quot;vim&quot;]

WORKDIR  /usr/src/app

ENV UV_LINK_MODE=copy

RUN \
    &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;image-mount&quot;&gt;--mount=type=cache,target=/root/.cache/uv \&lt;/span&gt;
    &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;image-mount&quot;&gt;--mount=type=bind,source=pyproject.toml,target=pyproject.toml \&lt;/span&gt;
    uv sync \
    --no-install-project

ENTRYPOINT [&quot;bash&quot;]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Nothing fancy. The &lt;samp&gt;astral/uv&lt;/samp&gt; base image already has Python and uv installed. I&#39;m using Python 3.11 because it&#39;s stable and well-supported. The &lt;samp&gt;uv sync&lt;/samp&gt; at build time installs dependencies from &lt;samp&gt;pyproject.toml&lt;/samp&gt;, and that cache mount makes rebuilds faster.&lt;/p&gt;

&lt;p&gt;The &lt;samp&gt;ENTRYPOINT [&quot;bash&quot;]&lt;/samp&gt; keeps the container running so I can exec into it and run commands. I&#39;m used to having PHP-FPM containers that stay up with their own service loop, and this achieves the same thing.&lt;/p&gt;

&lt;p&gt;One thing I&#39;m doing here differently from usual, is that I am using mount to &lt;em&gt;temporarily&lt;/em&gt; expose files to the Docker build process. In the past I would have copied &lt;samp class=&quot;xr xrd underline&quot; data-index=&quot;image-mount&quot;&gt;pyproject.toml&lt;/samp&gt; into the image file system. Why the change? Cos I did&#39;t realise I could do this until I saw it in this article I googled up: &quot;&lt;a href=&quot;https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers&quot; target=&quot;_blank&quot;&gt;Using uv in Docker &amp;rsaquo; Intermediate layers&lt;/a&gt;&quot;! I&#39;m gonna use this stragey from now on, I think&amp;hellip;&lt;/p&gt;

&lt;h3&gt;Project configuration and initial code&lt;/h3&gt;

&lt;p&gt;Python projects use &lt;samp&gt;pyproject.toml&lt;/samp&gt; for configuration - it&#39;s the equivalent of &lt;samp&gt;package.json&lt;/samp&gt; in Node.js or &lt;samp&gt;composer.json&lt;/samp&gt; in PHP. Here&#39;s the &lt;a href=&quot;https://github.com/adamcameron/learning-python/blob/3.0.2/pyproject.toml&quot; target=&quot;_blank&quot;&gt;initial setup&lt;/a&gt;:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;[project]
name = &quot;learning-python&quot;
version = &quot;0.1&quot;
description = &quot;And now I need to learn Python...&quot;
readme = &quot;README.md&quot;
requires-python = &quot;&amp;gt;=3.11&quot;
dependencies = []

[project.scripts]
howdy = &quot;learningpython.lp2.main:greet&quot;

[build-system]
requires = [&quot;uv_build&amp;gt;=0.8.15,&amp;lt;0.9.0&quot;]
build-backend = &quot;uv_build&quot;

[tool.uv.build-backend]
namespace = true
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;samp&gt;project.scripts&lt;/samp&gt; section defines a &lt;samp&gt;howdy&lt;/samp&gt; command that calls the &lt;samp&gt;greet&lt;/samp&gt; function from &lt;samp&gt;learningpython.main&lt;/samp&gt;. The syntax is &lt;samp&gt;module.path:function&lt;/samp&gt;. This makes the function callable via &lt;samp&gt;uv run howdy&lt;/samp&gt; from the command line.&lt;/p&gt;

&lt;p&gt;The &lt;samp&gt;namespace = true&lt;/samp&gt; bit tells uv to use namespace packages, which means I don&#39;t need &lt;samp&gt;__init__.py&lt;/samp&gt; files everywhere. Modern Python packaging is less fussy than it used to be.&lt;/p&gt;

&lt;p&gt;The actual code in &lt;a href=&quot;https://github.com/adamcameron/learning-python/blob/3.0.2/src/learningpython/lp2/main.py&quot; target=&quot;_blank&quot;&gt;src/learningpython/lp2/main.py&lt;/a&gt; is about as simple as it gets:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;def greet():
    print(&quot;Hello from learning-python!&quot;)

if __name__ == &quot;__main__&quot;:
    greet()
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Nothing to it. The &lt;samp&gt;if __name__ == &quot;__main__&quot;&lt;/samp&gt; bit means the function runs when you execute the file directly, but not when you import it as a module. Standard Python pattern.&lt;/p&gt;

&lt;p&gt;With all this in place, I could build and run the container:&lt;/p&gt;

&lt;div class=&quot;cliBox&quot;&gt;&lt;pre&gt;$ docker compose -f docker/docker-compose.yml up --detach
[+] Running 2/2
 ✔ Volume &quot;learning-python_venv&quot;  Created
 ✔ Container learning-python-python-1  Started

$ docker exec learning-python-python-1 uv run howdy
Hello from learning-python!
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Right. Basic container works, simple function prints output. Time to sort out testing.&lt;/p&gt;


&lt;h3&gt;Installing pytest and development dependencies&lt;/h3&gt;

&lt;p&gt;Python separates runtime dependencies from development dependencies. Runtime deps go in the &lt;samp&gt;dependencies&lt;/samp&gt; array, dev deps go in &lt;samp&gt;[dependency-groups]&lt;/samp&gt;. Things like test frameworks and linters are dev dependencies - you need them for development but not for running the actual application.&lt;/p&gt;

&lt;p&gt;To add pytest, I used &lt;samp&gt;uv add --dev pytest&lt;/samp&gt;. This is the Python equivalent of &lt;samp&gt;composer require --dev&lt;/samp&gt; in PHP or &lt;samp&gt;npm install --save-dev&lt;/samp&gt; in Node. The &lt;samp&gt;--dev&lt;/samp&gt; flag tells uv to put it in the dev dependency group rather than treating it as a runtime requirement.&lt;/p&gt;

&lt;p&gt;I wanted to pin pytest to major version 8, so I checked PyPI (&lt;a href=&quot;https://pypi.org/project/pytest/&quot; target=&quot;_blank&quot;&gt;pypi.org/project/pytest/&lt;/a&gt;) to see what was current. As of writing it&#39;s 8.4.2. Python uses different version constraint syntax than Composer - instead of &lt;samp&gt;^8.0&lt;/samp&gt; you write &lt;samp&gt;&amp;gt;=8.0,&amp;lt;9.0&lt;/samp&gt;. More verbose but explicit.&lt;/p&gt;

&lt;p&gt;I also wanted a file watcher like vitest has. There&#39;s &lt;samp&gt;pytest-watch&lt;/samp&gt; but it hasn&#39;t been maintained since 2020 and doesn&#39;t work with modern &lt;samp&gt;pyproject.toml&lt;/samp&gt; files. There&#39;s a newer alternative called &lt;samp&gt;pytest-watcher&lt;/samp&gt; that handles the modern Python tooling properly.&lt;/p&gt;

&lt;p&gt;After running &lt;samp&gt;uv add --dev pytest pytest-watcher&lt;/samp&gt;, the &lt;samp&gt;pyproject.toml&lt;/samp&gt; updated to include:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;[dependency-groups]
dev = [
    &quot;pytest&amp;gt;=8.4.2,&amp;lt;9&quot;,
    &quot;pytest-watcher&amp;gt;=0.4.3,&amp;lt;0.5&quot;,
]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;samp&gt;uv.lock&lt;/samp&gt; file pins the exact versions that were installed, giving reproducible builds. It&#39;s the Python equivalent of &lt;samp&gt;composer.lock&lt;/samp&gt; or &lt;samp&gt;package-lock.json&lt;/samp&gt;.&lt;/p&gt;

&lt;h3&gt;Writing the first test&lt;/h3&gt;

&lt;p&gt;pytest discovers test files automatically. It looks for files named &lt;samp&gt;test_*.py&lt;/samp&gt; or &lt;samp&gt;*_test.py&lt;/samp&gt; and runs functions in them that start with &lt;samp&gt;test_&lt;/samp&gt;. No configuration needed for basic usage.&lt;/p&gt;

&lt;p&gt;I created &lt;samp&gt;tests/lp2/test_main.py&lt;/samp&gt; to test the &lt;samp&gt;greet()&lt;/samp&gt; function. The test needed to verify that calling &lt;samp&gt;greet()&lt;/samp&gt; outputs the expected message to stdout. pytest has a built-in fixture called &lt;samp&gt;capsys&lt;/samp&gt; that captures output streams:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;from learningpython.lp2.main import greet

def test_greet(capsys):
    greet()
    captured = capsys.readouterr()
    assert captured.out == &quot;Hello from learning-python!\n&quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;samp&gt;capsys&lt;/samp&gt; parameter is a pytest fixture - you just add it as a function parameter and pytest provides it automatically. Calling &lt;samp&gt;readouterr()&lt;/samp&gt; gives you back stdout and stderr as a named tuple. The &lt;samp&gt;\n&lt;/samp&gt; at the end is because Python&#39;s &lt;samp&gt;print()&lt;/samp&gt; adds a newline by default.&lt;/p&gt;

&lt;p&gt;Running the test:&lt;/p&gt;

&lt;div class=&quot;cliBox&quot;&gt;&lt;pre&gt;$ docker exec learning-python-python-1 uv run pytest
======================================= test session starts ========================================
platform linux -- Python 3.11.13, pytest-8.4.2, pluggy-1.6.0
rootdir: /usr/src/app
configfile: pyproject.toml
collected 1 item

tests/lp2/test_main.py .                                                                     [100%]

======================================== 1 passed in 0.01s =========================================
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Green. The test found the &lt;samp&gt;pyproject.toml&lt;/samp&gt; config automatically and discovered the test file without needing to tell it where to look.&lt;/p&gt;

&lt;p&gt;For continuous testing, &lt;samp&gt;pytest-watcher&lt;/samp&gt; monitors files and re-runs tests on changes:&lt;/p&gt;

&lt;div class=&quot;cliBox&quot;&gt;&lt;pre&gt;$ docker exec learning-python-python-1 uv run ptw
[ptw] Watching directories: [&#39;src&#39;, &#39;tests&#39;]
[ptw] Running: pytest
======================================= test session starts ========================================
platform linux -- Python 3.11.13, pytest-8.4.2, pluggy-1.6.0
rootdir: /usr/src/app
configfile: pyproject.toml
collected 1 item

tests/lp2/test_main.py .                                                                     [100%]

======================================== 1 passed in 0.01s =========================================
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Any time I change a file in &lt;samp&gt;src&lt;/samp&gt; or &lt;samp&gt;tests&lt;/samp&gt;, it automatically re-runs the relevant tests. Much faster feedback loop than running tests manually each time.&lt;/p&gt;

&lt;h3&gt;Code formatting and linting with ruff&lt;/h3&gt;

&lt;p&gt;Python has a bunch of tools for code quality - &lt;samp&gt;black&lt;/samp&gt; for formatting, &lt;samp&gt;flake8&lt;/samp&gt; for linting, &lt;samp&gt;isort&lt;/samp&gt; for import sorting. Or you can just use &lt;samp&gt;ruff&lt;/samp&gt;, which consolidates all of that into one fast tool written in Rust.&lt;/p&gt;

&lt;p&gt;Installation was the same pattern: &lt;samp&gt;uv add --dev ruff&lt;/samp&gt;. This added &lt;samp&gt;&quot;ruff&amp;gt;=0.8.4,&amp;lt;0.9&quot;&lt;/samp&gt; to the dev dependencies.&lt;/p&gt;

&lt;p&gt;ruff has two main commands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;samp&gt;ruff check&lt;/samp&gt; - linting (finds unused variables, style issues, code problems)&lt;/li&gt;
&lt;li&gt;&lt;samp&gt;ruff format&lt;/samp&gt; - formatting (fixes indentation, spacing, line length)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Testing it out with some deliberately broken code:&lt;/p&gt;

&lt;div class=&quot;cliBox&quot;&gt;&lt;pre&gt;$ docker exec learning-python-python-1 uvx ruff check src/learningpython/lp2/main.py
F841 Local variable `a` is assigned to but never used
 --&amp;gt; src/learningpython/lp2/main.py:2:8
  |
1 | def greet():
2 |        a = &quot;wootywoo&quot;
  |        ^
3 |        print(&quot;Hello from learning-python!&quot;)
  |
help: Remove assignment to unused variable `a`
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;It caught the unused variable. It also didn&#39;t complain about the 7-space indentation, because &lt;samp&gt;ruff check&lt;/samp&gt; is about code issues, not formatting. That&#39;s what &lt;samp&gt;ruff format&lt;/samp&gt; is for:&lt;/p&gt;

&lt;div class=&quot;cliBox&quot;&gt;&lt;pre&gt;$ docker exec learning-python-python-1 uvx ruff format src/learningpython/lp2/main.py
1 file reformatted
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This fixed the indentation to Python&#39;s standard 4 spaces. The &lt;samp&gt;check&lt;/samp&gt; command can also auto-fix some issues with &lt;samp&gt;--fix&lt;/samp&gt;, similar to eslint.&lt;/p&gt;

&lt;p&gt;I configured IntelliJ to run &lt;samp&gt;ruff format&lt;/samp&gt; on save. Had to disable a conflicting AMD Adrenaline hotkey first - video driver software stealing IDE shortcuts is always fun to debug. It took about an hour to work out WTF was going on there. I really don&#39;t understand why AMD thinks its driver software needs &lt;strong&gt;hotkeys&lt;/strong&gt;. Dorks.&lt;/p&gt;

&lt;h3&gt;A Python gotcha: hyphens in paths&lt;/h3&gt;

&lt;p&gt;I reorganised the code by ticket number, so I moved the erstwhile &lt;samp&gt;main.py&lt;/samp&gt; to &lt;samp&gt;src/learningpython/lp-2/main.py&lt;/samp&gt;. Updated the &lt;samp&gt;pyproject.toml&lt;/samp&gt; entry point to match:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;[project.scripts]
howdy = &quot;learningpython.lp-2.main:greet&quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This did not go well:&lt;/p&gt;

&lt;div class=&quot;cliBox&quot;&gt;&lt;pre&gt;$ docker exec learning-python-python-1 uv run howdy
      Built learning-python @ file:///usr/src/app
Uninstalled 1 package in 0.37ms
Installed 1 package in 1ms
  File &quot;/usr/src/app/.venv/bin/howdy&quot;, line 4
    from learningpython.lp-2.main import greet
                            ^
SyntaxError: invalid decimal literal
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Python&#39;s import system doesn&#39;t support hyphens in module names. When it sees &lt;samp&gt;lp-2&lt;/samp&gt;, it tries to parse it as &quot;lp minus 2&quot; and chokes. Module names need to be valid Python identifiers, which means letters, numbers, and underscores only.&lt;/p&gt;

&lt;p&gt;Renaming to &lt;samp&gt;lp2&lt;/samp&gt; fixed it. No hyphens in directory names if those directories are part of the import path. You can use hyphens in filenames that you access directly (like &lt;samp&gt;python path/to/some-script.py&lt;/samp&gt;), but not in anything you&#39;re importing as a module.&lt;/p&gt;

&lt;p&gt;This caught me out because hyphens are fine in most other ecosystems. Coming from PHP and JavaScript where &lt;samp&gt;some-module-name&lt;/samp&gt; is perfectly normal, Python&#39;s stricter rules take some adjustment.&lt;/p&gt;

&lt;h3&gt;Wrapping up&lt;/h3&gt;

&lt;p&gt;So that&#39;s the development environment sorted. Docker container running Python 3.11 with uv for dependency management. pytest for testing with pytest-watcher for continuous test runs. ruff handling both linting and formatting. All the basics for writing Python code without things being annoying.&lt;/p&gt;

&lt;p&gt;The final project structure looks like this:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;learning-python/
├── docker/
│   ├── docker-compose.yml
│   └── python/
│       └── Dockerfile
├── src/
│   └── learningpython/
│       └── lp2/
│           └── main.py
├── tests/
│   └── lp2/
│       └── test_main.py
├── pyproject.toml
└── uv.lock
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Everything&#39;s on GitHub at &lt;a href=&quot;https://github.com/adamcameron/learning-python/tree/3.0.2&quot; target=&quot;_blank&quot;&gt;github.com/adamcameron/learning-python&lt;/a&gt; (tag 3.0.2).&lt;/p&gt;

&lt;p&gt;Now I can actually start learning Python instead of fighting with tooling. Which is the point.&lt;/p&gt;

&lt;p&gt;Righto.&lt;/p&gt;

&lt;p&gt;--&lt;br&gt;Adam&lt;/p&gt;
</content><link rel='edit' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/2952785678071236871'/><link rel='self' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/2952785678071236871'/><link rel='alternate' type='text/html' href='https://blog.adamcameron.me/2025/10/setting-up-python-learning-environment.html' title='Setting up a Python learning environment: Docker, pytest, and ruff'/><author><name>Adam Cameron</name><uri>http://www.blogger.com/profile/04830762402027484810</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author></entry><entry><id>tag:blogger.com,1999:blog-8141574561530432909.post-5517154533056708370</id><published>2025-10-04T20:46:00.004+00:00</published><updated>2025-10-04T20:50:12.154+00:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Blog"/><title type='text'>Violating Blogger&#39;s community guidelines. Apparently.</title><content type='html'>&lt;p&gt;G&#39;day:&lt;/p&gt;

&lt;p&gt;Earlier this evening I published &lt;a href=&quot;https://blog.adamcameron.me/2025/10/typescript-decorators-not-actually.html&quot;&gt;TypeScript decorators: not actually decorators&lt;/a&gt;. And about 5min after it went live, it vanished. Weird. Looking in the back-end of Blogger, I see this warning:&lt;/p&gt;

&lt;div class=&quot;updateBox&quot;&gt;This post was unpublished because it violates Blogger&#39;s community guidelines. To republish, please update the content to adhere to the guidelines.&lt;/div&gt;

&lt;p&gt;What? Seriously?&lt;/p&gt;

&lt;p&gt;Looking through my junk folder in my email client, I had an email thus:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hello,&lt;/p&gt;

&lt;p&gt;As you may know, our Community Guidelines (&lt;a href=&quot;https://blogger.com/go/contentpolicy&quot; target=&quot;_blank&quot;&gt;https://blogger.com/go/contentpolicy&lt;/a&gt;) describe the boundaries for what we allow – and don&#39;t allow – on Blogger. Your post titled &#39;TypeScript decorators: not actually decorators&#39; was flagged to us for review. We have determined that it violates our guidelines and have unpublished the URL &lt;samp&gt;https://blog.adamcameron.me/2025/10/typescript-decorators-not-actually.html&lt;/samp&gt;, making it unavailable to blog readers.&lt;/p&gt;

&lt;p&gt;If you are interested in republishing the post, please update the content to adhere to Blogger&#39;s Community Guidelines. Once the content has been updated, you may republish it at [URL removed]. This will trigger a review of the post.&lt;/p&gt;

&lt;p&gt;You may have the option to pursue your claims in court. If you have legal questions or wish to examine legal options that may be available to you, you may want to consult your own legal counsel.&lt;/p&gt;

&lt;p&gt;For more information, please review the following resources:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Terms of Service: &lt;a href=&quot;https://www.blogger.com/go/terms&quot; target=&quot;_blank&quot;&gt;https://www.blogger.com/go/terms&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Blogger Community Guidelines: https://blogger.com/go/contentpolicy&lt;/li&gt;
  &lt;/ul&gt;
&lt;p&gt;Sincerely,&lt;/p&gt;
&lt;p&gt;The Blogger Team&lt;/p&gt;
  &lt;/blockquote&gt;

&lt;p&gt;&quot;OK,&quot; I thought. &quot;I&#39;ll play yer silly game&quot;, knowing full-well I had done nothing to violate any sane T&amp;amp;Cs / guidelines. You can review the guidance yerself: obvs there&#39;s nothing in the article that comes anywhere near close to butting up against any of those rules.&lt;/p&gt;

&lt;p&gt;I did make a coupla edits and resubmitted it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Updated text in the first para to read &quot;what the &lt;em&gt;heck&lt;/em&gt;&quot;. You can imagine what it said before the edit. Not the only instance of that word in this blog, as one can imagine.&lt;/li&gt;
  &lt;li&gt;I was using my son&#39;s name instead of &quot;Jed Dough&quot;. I have used Z&#39;s name a lot in the past, so can&#39;t see it was that.&lt;/li&gt;
  &lt;li&gt;I used a very cliched common password as sample data in place of &lt;samp&gt;tough_to_guess&lt;/samp&gt;.&lt;/li&gt;
  &lt;li&gt;I removed most of one para. &lt;a href=&quot;https://blog.adamcameron.me/2025/10/typescript-decorators-not-actually.html#updated-para&quot;&gt;The para starting &quot;Worth learning?&quot;&lt;/a&gt; went on to explain how some noted TypeScript frameworks used decorators heavily. Why did I remove this? Well: &lt;a href=&quot;https://claude.ai/&quot; target=&quot;_blank&quot;&gt;Claudia&lt;/a&gt; wrote it, and this came from her knowledge not my own. &lt;em&gt;I&lt;/em&gt; didn&#39;t know those frameworks even existed, let alone used decorators. I admonished her for using original &quot;research&quot;, but I also went through and verified that she was correct in what she was saying. To me this was harmless and useful info: but it wasn&#39;t my own work, so I thought I&#39;d get rid. I &lt;em&gt;had&lt;/em&gt; included a note there that it was her and not me. There&#39;s nothing in the T&amp;amp;Cs that said one cannot use AI to help writing these articles, but I know people are getting a bit pearl-clutchy about the whole thing ATM, so figured it might be that. Daft though, given it was an &lt;em&gt;admission&lt;/em&gt; it was AI-written; rather than try to shadily pass AI work as my own. Which, if you read this blog, I don&#39;t do. I always say when she&#39;s helped me draft things. And I always read what she&#39;s done ands tweak where necessary anyhow. It&#39;s &lt;em&gt;my&lt;/em&gt; work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And that was it. But maybe 30min later I got another email from them:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hello,&lt;/p&gt;

&lt;p&gt;We have re-evaluated the post titled &#39;TypeScript decorators: not actually decorators&#39; against our Community Guidelines (https://blogger.com/go/contentpolicy). Upon review, the post has been reinstated. You may access the post at https://blog.adamcameron.me/2025/10/typescript-decorators-not-actually.html.&lt;/p&gt;

&lt;p&gt;Sincerely,&lt;br&gt;
The Blogger Team&lt;/p&gt;
&lt;/blockquote&gt;
  
&lt;p&gt;Cool. No harm done, but I&#39;d really like to know what triggered it. Of course they can&#39;t tell me as that would be leaking info that bad-actors could then use to circumvent their system. I get that. And it&#39;s better to err on the side of caution in these matters I guess.&lt;/p&gt;

&lt;p&gt;Anyway, that was a thing.&lt;/p&gt;

&lt;p&gt;Righto.&lt;/p&gt;

&lt;p&gt;-- &lt;br&gt;Adam (who wrote every word of this one. How bloody tedious)&lt;/p&gt;

</content><link rel='edit' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/5517154533056708370'/><link rel='self' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/5517154533056708370'/><link rel='alternate' type='text/html' href='https://blog.adamcameron.me/2025/10/violating-bloggers-community-guidelines.html' title='Violating Blogger&#39;s community guidelines. Apparently.'/><author><name>Adam Cameron</name><uri>http://www.blogger.com/profile/04830762402027484810</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author></entry><entry><id>tag:blogger.com,1999:blog-8141574561530432909.post-5783789060522649456</id><published>2025-10-04T16:36:00.010+00:00</published><updated>2025-10-04T20:33:20.386+00:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Javascript"/><category scheme="http://www.blogger.com/atom/ns#" term="TypeScript"/><title type='text'>TypeScript decorators: not actually decorators</title><content type='html'>&lt;p&gt;G&#39;day:&lt;/p&gt;

&lt;p&gt;I&#39;ve been working through TypeScript classes, and when I got to decorators I hit the &lt;samp&gt;@&lt;/samp&gt; syntax and thought &quot;hang on, what the heck is all this doing &lt;em&gt;inside&lt;/em&gt; the class being decorated? The class shouldn&#39;t know it&#39;s being decorated. Fundamentally it &lt;strong&gt;shouldn&#39;t know&lt;/strong&gt;.&quot;&lt;/p&gt;

&lt;p&gt;Turns out TypeScript decorators have bugger all to do with the Gang of Four decorator pattern. They&#39;re not about wrapping objects at runtime to extend behavior. They&#39;re metaprogramming annotations - more like Java&#39;s &lt;samp&gt;@annotations&lt;/samp&gt; or C#&#39;s &lt;samp&gt;[attributes]&lt;/samp&gt; - that modify class declarations at design time using the &lt;samp&gt;@&lt;/samp&gt; syntax.&lt;/p&gt;

&lt;p&gt;The terminology collision is unfortunate. Python had the same debate back in &lt;a href=&quot;https://peps.python.org/pep-0318/#on-the-name-decorator&quot; target=&quot;_blank&quot;&gt;PEP 318&lt;/a&gt; - people pointed out that &quot;decorator&quot; was already taken by a well-known design pattern, but they went with it anyway because the syntax visually &quot;decorates&quot; the function definition. TypeScript followed Python&#39;s lead: borrowed the &lt;samp&gt;@&lt;/samp&gt; syntax, borrowed the confusing name, and now we&#39;re stuck with it.&lt;/p&gt;

&lt;p&gt;So this isn&#39;t about the decorator pattern at all. This is about TypeScript&#39;s metaprogramming features that happen to be called decorators for historical reasons that made sense to someone, somewhere.&lt;/p&gt;

&lt;h3&gt;What TypeScript deco&lt;h3&gt;What TypeScript decorators actually do&lt;/h3&gt;

&lt;p&gt;A decorator in TypeScript is a function that takes a target (the thing being decorated - a class, method, property, whatever) and a context object, and optionally returns a replacement. They execute at class definition time, not at runtime.&lt;/p&gt;

&lt;p&gt;The simplest example is a getter decorator:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;xr xrt&quot; data-index=&quot;decorator-function&quot;&gt;function obscurer(&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;decorator-function&quot;&gt;originalMethod: (this: PassPhrase) =&gt; string,&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;decorator-function&quot;&gt;context: ClassGetterDecoratorContext&lt;/span&gt;
&lt;span class=&quot;xr xrt&quot; data-index=&quot;decorator-function&quot;&gt;) {&lt;/span&gt;
  void context
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-replacement&quot;&gt;function replacementMethod(this: PassPhrase) {&lt;/span&gt;
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;clone-this&quot;&gt;const duplicateOfThis: PassPhrase = Object.assign(&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;clone-this&quot;&gt;Object.create(Object.getPrototypeOf(this) as PassPhrase),&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;clone-this&quot;&gt;this,&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;clone-this&quot;&gt;{ _text: this._text.replace(/./g, &#39;*&#39;) }&lt;/span&gt;
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;clone-this&quot;&gt;) as PassPhrase&lt;/span&gt;

    &lt;span class=&quot;xr xrt&quot; data-index=&quot;call-original&quot;&gt;return originalMethod.call(duplicateOfThis)&lt;/span&gt;
  }

  &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-replacement&quot;&gt;return replacementMethod&lt;/span&gt;
}

export class PassPhrase {
  constructor(protected _text: string) {}

  get plainText(): string {
    return this._text
  }

  &lt;span class=&quot;xr xrt&quot; data-index=&quot;apply-decorator&quot;&gt;@obscurer&lt;/span&gt;
  get obscuredText(): string {
    return this._text
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.20/src/lt-31/accessor.ts&quot; target=&quot;_blank&quot;&gt;accessor.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;decorator-function&quot;&gt;decorator function&lt;/span&gt; receives the original getter and &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;return-replacement&quot;&gt;returns a replacement&lt;/span&gt; that &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;clone-this&quot;&gt;creates a modified copy of &lt;samp&gt;this&lt;/samp&gt;, replaces the &lt;samp&gt;_text&lt;/samp&gt; property with asterisks&lt;/span&gt;, then &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;call-original&quot;&gt;calls the original getter with that modified context&lt;/span&gt;. The original instance is untouched - we&#39;re not mutating state, we&#39;re intercepting the call and providing different data to work with. The &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;apply-decorator&quot;&gt;&lt;samp&gt;@obscurer&lt;/samp&gt; syntax&lt;/span&gt; applies the decorator to the getter.&lt;/p&gt;

&lt;p&gt;The test shows this in action:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;it(&#39;original text remains unchanged&#39;, () =&gt; {
  const phrase = new PassPhrase(&#39;tough_to_guess&#39;)
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;obscured-result&quot;&gt;expect(phrase.obscuredText).toBe(&#39;**************&#39;)&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;plain-result&quot;&gt;expect(phrase.plainText).toBe(&#39;tough_to_guess&#39;)&lt;/span&gt;
})&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.20/tests/lt-31/accessor.test.ts&quot; target=&quot;_blank&quot;&gt;accessor.test.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;obscured-result&quot;&gt;&lt;samp&gt;obscuredText&lt;/samp&gt; getter returns asterisks&lt;/span&gt;, the &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;plain-result&quot;&gt;&lt;samp&gt;plainText&lt;/samp&gt; getter returns the original value&lt;/span&gt;. The decorator wraps one getter without affecting the other or mutating the underlying &lt;samp&gt;_text&lt;/samp&gt; property.&lt;/p&gt;

&lt;h3&gt;Method decorators and decorator factories&lt;/h3&gt;

&lt;p&gt;Method decorators work the same way as getter decorators, except they handle methods with actual parameters. More interesting is the decorator factory pattern - a function that returns a decorator, allowing runtime configuration.&lt;/p&gt;

&lt;p&gt;Here&#39;s an authentication service with logging:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;interface Logger {
  log(message: string): void
}

&lt;span class=&quot;xr xrt&quot; data-index=&quot;console-as-logger&quot;&gt;const defaultLogger: Logger = console&lt;/span&gt;

export class AuthenticationService {
  constructor(private directoryServiceAdapter: DirectoryServiceAdapter) {}

  &lt;span class=&quot;xr xrt&quot; data-index=&quot;apply-factory&quot;&gt;@logAuth()&lt;/span&gt;
  authenticate(userName: string, password: string): boolean {
    const result: boolean = this.directoryServiceAdapter.authenticate(
      userName,
      password
    )
    if (!result) {
      throw new AuthenticationException(
        `Authentication failed for user ${userName}`
      )
    }
    return result
  }
}

&lt;span class=&quot;xr xrt&quot; data-index=&quot;factory-function&quot;&gt;function logAuth(logger: Logger = defaultLogger) {&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-decorator&quot;&gt;return function (&lt;/span&gt;
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-decorator&quot;&gt;originalMethod: (&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-decorator&quot;&gt;this: AuthenticationService,&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-decorator&quot;&gt;userName: string,&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-decorator&quot;&gt;password: string&lt;/span&gt;
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-decorator&quot;&gt;) =&gt; boolean,&lt;/span&gt;
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-decorator&quot;&gt;context: ClassMethodDecoratorContext&lt;&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-decorator&quot;&gt;AuthenticationService,&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-decorator&quot;&gt;(userName: string, password: string) =&gt; boolean&lt;/span&gt;
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-decorator&quot;&gt;&gt;&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;return-decorator&quot;&gt;) {&lt;/span&gt;
    void context
    function replacementMethod(
      this: AuthenticationService,
      userName: string,
      password: string
    ) {
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;log-before&quot;&gt;logger.log(`Authenticating user ${userName}`)&lt;/span&gt;
      try {
        const result = originalMethod.call(this, userName, password)
        &lt;span class=&quot;xr xrt&quot; data-index=&quot;log-success&quot;&gt;logger.log(`User ${userName} authenticated successfully`)&lt;/span&gt;
        return result
      } catch (e) {
        &lt;span class=&quot;xr xrt&quot; data-index=&quot;log-failure&quot;&gt;logger.log(`Authentication failed for user ${userName}: ${e}`)&lt;/span&gt;
        throw e
      }
    }
    return replacementMethod
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.20/src/lt-31/method.ts&quot; target=&quot;_blank&quot;&gt;method.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;factory-function&quot;&gt;factory function takes a logger parameter&lt;/span&gt; and &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;return-decorator&quot;&gt;returns the actual decorator function&lt;/span&gt;. The decorator wraps the method with logging: &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;log-before&quot;&gt;logs before calling&lt;/span&gt;, &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;log-success&quot;&gt;logs on success&lt;/span&gt;, &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;log-failure&quot;&gt;logs on failure and re-throws&lt;/span&gt;. The &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;apply-factory&quot;&gt;&lt;samp&gt;@logAuth()&lt;/samp&gt; syntax&lt;/span&gt; calls the factory which returns the decorator.&lt;/p&gt;

&lt;p&gt;Worth noting: the logger has to be configured at module level because &lt;samp&gt;@logAuth()&lt;/samp&gt; executes when the class is defined, not when instances are created. This means tests can&#39;t easily inject different loggers per instance - you&#39;re stuck with whatever was configured when the file loaded. It&#39;s a limitation of how decorators work, and honestly it&#39;s a bit crap for dependency injection.&lt;/p&gt;

&lt;p&gt;Also note &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;console-as-logger&quot;&gt;I&#39;m just using the console as the logger here&lt;/span&gt;. It makes testing easy.&lt;/p&gt;

&lt;h3&gt;Class decorators and shared state&lt;/h3&gt;

&lt;p&gt;Class decorators can replace the entire class, including hijacking the constructor. This example is thoroughly contrived but demonstrates how decorators can inject stateful behavior that persists across all instances:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;&lt;span class=&quot;xr xrt&quot; data-index=&quot;module-state&quot;&gt;const maoriNumbers = [&#39;tahi&#39;, &#39;rua&#39;, &#39;toru&#39;, &#39;wha&#39;]&lt;/span&gt;
&lt;span class=&quot;xr xrt&quot; data-index=&quot;module-state&quot;&gt;let current = 0&lt;/span&gt;
&lt;span class=&quot;xr xrt&quot; data-index=&quot;generator&quot;&gt;function* generator() {&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;generator&quot;&gt;while (current &lt; maoriNumbers.length) {&lt;/span&gt;
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;generator&quot;&gt;yield maoriNumbers[current++]&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;generator&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;generator&quot;&gt;throw new Error(&#39;No more Maori numbers&#39;)&lt;/span&gt;
&lt;span class=&quot;xr xrt&quot; data-index=&quot;generator&quot;&gt;}&lt;/span&gt;

function maoriSequence(
  target: typeof Number,
  context: ClassDecoratorContext&lt;typeof Number&gt;
) {
  void context

  &lt;span class=&quot;xr xrt&quot; data-index=&quot;replace-class&quot;&gt;return class extends target {&lt;/span&gt;
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;override-value&quot;&gt;_value = generator().next().value as string&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;replace-class&quot;&gt;}&lt;/span&gt;
}

type NullableString = string | null

&lt;span class=&quot;xr xrt&quot; data-index=&quot;apply-class-decorator&quot;&gt;@maoriSequence&lt;/span&gt;
export class Number {
  constructor(protected _value: NullableString = null) {}

  get value(): NullableString {
    return this._value
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.20/src/lt-31/class.ts&quot; target=&quot;_blank&quot;&gt;class.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;apply-class-decorator&quot;&gt;class decorator&lt;/span&gt; &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;replace-class&quot;&gt;returns a new class that extends the original&lt;/span&gt;, &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;override-value&quot;&gt;overriding the &lt;samp&gt;_value&lt;/samp&gt; property with the next value from a generator&lt;/span&gt;. The &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;generator&quot;&gt;generator&lt;/span&gt; and its &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;module-state&quot;&gt;state live at module scope&lt;/span&gt;, so they&#39;re shared across all instances of the class. Each time you create a new instance, the constructor parameter gets completely ignored and the decorator forces the next Maori number instead:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;it(&#39;intercepts the constructor&#39;, () =&gt; {
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;first-call&quot;&gt;expect(new Number().value).toEqual(&#39;tahi&#39;)&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;second-call&quot;&gt;expect(new Number().value).toEqual(&#39;rua&#39;)&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;third-call&quot;&gt;expect(new Number().value).toEqual(&#39;toru&#39;)&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;fourth-call&quot;&gt;expect(new Number().value).toEqual(&#39;wha&#39;)&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;exhausted&quot;&gt;expect(() =&gt; new Number()).toThrowError(&#39;No more Maori numbers&#39;)&lt;/span&gt;
})&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.20/tests/lt-31/class.test.ts&quot; target=&quot;_blank&quot;&gt;class.test.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;span class=&quot;xr xrd underline&quot; data-index=&quot;first-call&quot;&gt;First instance gets &#39;tahi&#39;&lt;/span&gt;, &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;second-call&quot;&gt;second gets &#39;rua&#39;&lt;/span&gt;, &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;third-call&quot;&gt;third gets &#39;toru&#39;&lt;/span&gt;, &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;fourth-call&quot;&gt;fourth gets &#39;wha&#39;&lt;/span&gt;, and the &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;exhausted&quot;&gt;fifth throws an error because the generator is exhausted&lt;/span&gt;. The state persists across all instantiations because it&#39;s in the decorator&#39;s closure at module level.&lt;/p&gt;

&lt;p&gt;This demonstrates that class decorators can completely hijack construction and maintain shared state, which is both powerful and horrifying. You&#39;d never actually do this in real code - it&#39;s terrible for testing, debugging, and reasoning about behavior - but it shows the level of control decorators have over class behavior.&lt;/p&gt;

&lt;p&gt;GitHub Copilot&#39;s code review was appropriately horrified by this. It flagged the module-level state, the generator that never resets, the constructor hijacking, and basically everything else about this approach. Fair cop - the code reviewer was absolutely right to be suspicious. This is demonstration code showing what&#39;s &lt;em&gt;possible&lt;/em&gt; with decorators, not what you should &lt;em&gt;actually do&lt;/em&gt;. In real code, if you find yourself maintaining stateful generators at module scope that exhaust after four calls and hijack constructors to ignore their parameters, you&#39;ve gone badly wrong somewhere and need to step back and reconsider your life choices.&lt;/p&gt;



&lt;h3&gt;Auto-accessors and the &lt;samp&gt;accessor&lt;/samp&gt; keyword&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#auto-accessors-in-classes&quot; target=&quot;_blank&quot;&gt;Auto-accessors&lt;/a&gt; are a newer feature that provides shorthand for creating getter/setter pairs with a private backing field. The &lt;samp&gt;accessor&lt;/samp&gt; keyword does automatically what you&#39;d normally write manually:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export class Person {
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;auto-accessor&quot;&gt;@logCalls(defaultLogger)&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;auto-accessor&quot;&gt;accessor firstName: string&lt;/span&gt;

  &lt;span class=&quot;xr xrt&quot; data-index=&quot;auto-accessor&quot;&gt;@logCalls(defaultLogger)&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;auto-accessor&quot;&gt;accessor lastName: string&lt;/span&gt;

  constructor(firstName: string, lastName: string) {
    this.firstName = firstName
    this.lastName = lastName
  }

  getFullName(): string {
    return `${this.firstName} ${this.lastName}`
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.20/src/lt-31/autoAccessors.ts&quot; target=&quot;_blank&quot;&gt;autoAccessors.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;auto-accessor&quot;&gt;&lt;samp&gt;accessor&lt;/samp&gt; keyword creates a private backing field plus public getter and setter&lt;/span&gt;, similar to C# auto-properties. The decorator can then wrap both operations:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;function logCalls(logger: Logger = defaultLogger) {
  return function &lt;This, Return&gt;(
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;accessor-target&quot;&gt;target: ClassAccessorDecoratorTarget&lt;This, Return&gt;,&lt;/span&gt;
    context: ClassAccessorDecoratorContext&lt;This, Return&gt;
  ) {
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;accessor-result&quot;&gt;const result: ClassAccessorDecoratorResult&lt;This, Return&gt; = {&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;wrap-get&quot;&gt;get(this: This) {&lt;/span&gt;
        &lt;span class=&quot;xr xrt&quot; data-index=&quot;wrap-get&quot;&gt;logger.log(`[${String(context.name)}] getter called`)&lt;/span&gt;
        &lt;span class=&quot;xr xrt&quot; data-index=&quot;wrap-get&quot;&gt;return target.get.call(this)&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;wrap-get&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;wrap-set&quot;&gt;set(this: This, value) {&lt;/span&gt;
        &lt;span class=&quot;xr xrt&quot; data-index=&quot;wrap-set&quot;&gt;logger.log(&lt;/span&gt;
          &lt;span class=&quot;xr xrt&quot; data-index=&quot;wrap-set&quot;&gt;`[${String(context.name)}] setter called with value [${String(value)}]`&lt;/span&gt;
        &lt;span class=&quot;xr xrt&quot; data-index=&quot;wrap-set&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;xr xrt&quot; data-index=&quot;wrap-set&quot;&gt;target.set.call(this, value)&lt;/span&gt;
      &lt;span class=&quot;xr xrt&quot; data-index=&quot;wrap-set&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;accessor-result&quot;&gt;}&lt;/span&gt;

    return result
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.20/src/lt-31/autoAccessors.ts&quot; target=&quot;_blank&quot;&gt;autoAccessors.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;accessor-target&quot;&gt;target provides access to the original get and set methods&lt;/span&gt;, and the decorator &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;accessor-result&quot;&gt;returns a result object with replacement implementations&lt;/span&gt;. The &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;wrap-get&quot;&gt;getter wraps the original with logging before calling it&lt;/span&gt;, and the &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;wrap-set&quot;&gt;setter does the same&lt;/span&gt;.&lt;/p&gt;

&lt;p&gt;Testing shows both operations getting logged:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;it(&#39;should log the setters being called&#39;, () =&gt; {
  const consoleSpy = vi.spyOn(console, &#39;log&#39;).mockImplementation(() =&gt; {})
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;constructor-sets&quot;&gt;new Person(&#39;Jed&#39;, &#39;Dough&#39;)&lt;/span&gt;

  &lt;span class=&quot;xr xrt&quot; data-index=&quot;setter-logged&quot;&gt;expect(consoleSpy).toHaveBeenCalledWith(&lt;/span&gt;
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;setter-logged&quot;&gt;&#39;[firstName] setter called with value [Jed]&#39;&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;setter-logged&quot;&gt;)&lt;/span&gt;
  expect(consoleSpy).toHaveBeenCalledWith(
    &#39;[lastName] setter called with value [Dough]&#39;
  )
})

it(&#39;should log the getters being called&#39;, () =&gt; {
  const consoleSpy = vi.spyOn(console, &#39;log&#39;).mockImplementation(() =&gt; {})
  const person = new Person(&#39;Jed&#39;, &#39;Dough&#39;)

  &lt;span class=&quot;xr xrt&quot; data-index=&quot;getters-called&quot;&gt;expect(person.getFullName()).toBe(&#39;Jed Dough&#39;)&lt;/span&gt;
  &lt;span class=&quot;xr xrt&quot; data-index=&quot;getter-logged&quot;&gt;expect(consoleSpy).toHaveBeenCalledWith(&#39;[firstName] getter called&#39;)&lt;/span&gt;
  expect(consoleSpy).toHaveBeenCalledWith(&#39;[lastName] getter called&#39;)
})&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.20/tests/lt-31/autoAccessors.test.ts&quot; target=&quot;_blank&quot;&gt;autoAccessors.test.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;constructor-sets&quot;&gt;constructor assignments trigger the setters&lt;/span&gt;, which get &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;setter-logged&quot;&gt;logged&lt;/span&gt;. Later when &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;getters-called&quot;&gt;&lt;samp&gt;getFullName()&lt;/samp&gt; accesses the properties&lt;/span&gt;, the &lt;span class=&quot;xr xrd underline&quot; data-index=&quot;getter-logged&quot;&gt;getters are logged&lt;/span&gt;.&lt;/p&gt;

&lt;p&gt;Auto-accessors are actually quite practical compared to the other decorator types. They provide a clean way to add cross-cutting concerns like logging, validation, or change tracking to properties without cluttering the class with boilerplate getter/setter implementations.&lt;/p&gt;

&lt;h3&gt;What I learned&lt;/h3&gt;

&lt;p&gt;TypeScript decorators are metaprogramming tools that modify class behavior at design time. They&#39;re useful for cross-cutting concerns like logging, validation, or instrumentation - the kinds of things that would otherwise clutter your actual business logic.&lt;/p&gt;

&lt;p&gt;The main decorator types are:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Getter/setter decorators - wrap property access&lt;/li&gt;
  &lt;li&gt;Method decorators - wrap method calls&lt;/li&gt;
  &lt;li&gt;Class decorators - replace or modify entire classes&lt;/li&gt;
  &lt;li&gt;Auto-accessor decorators - wrap the getter/setter pairs created by the &lt;samp&gt;accessor&lt;/samp&gt; keyword&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Decorator factories (functions that return decorators) allow runtime configuration, though &quot;runtime&quot; here means &quot;when the module loads&quot;, not &quot;when instances are created&quot;. This makes dependency injection awkward - you&#39;re stuck with module-level state or global configuration.&lt;/p&gt;

&lt;p&gt;The syntax is straightforward once you understand the pattern: decorator receives target and context, returns replacement (or modifies via context), job done. The tricky bit is the type signatures and making sure your implementation signature is flexible enough to handle all the overloads you&#39;re declaring.&lt;/p&gt;

&lt;p&gt;But fundamentally, these aren&#39;t decorators in the design pattern sense. They&#39;re annotations that modify declarations. If you&#39;re coming from a language with proper decorators (the GoF pattern), you&#39;ll need to context-switch your brain because the &lt;samp&gt;@&lt;/samp&gt; syntax is doing something completely different here.&lt;/p&gt;

&lt;p id=&quot;updated-para&quot;&gt;Worth learning? Yeah, if only because you&#39;ll see them in the wild and need to understand what they&#39;re doing.&lt;/p&gt;



&lt;p&gt;Would I use them in my own code? Probably sparingly. Auto-accessors are legitimately useful. Method decorators for logging or metrics could work if you&#39;re comfortable with the module-level configuration limitations. Class decorators that hijack constructors and maintain shared state can absolutely get in the sea.&lt;/p&gt;

&lt;p&gt;But to be frank: if I wanted to decorate something - in the accurate sense of that term - I&#39;d do it properly using the design pattern, and DI.&lt;/p&gt;

&lt;hr&gt;

&lt;p&gt;The full code for this investigation is in my &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.20/src/lt-31&quot; target=&quot;_blank&quot;&gt;learning-typescript repository&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Righto.&lt;/p&gt;

&lt;p&gt;--&lt;br&gt;Adam&lt;/p&gt;</content><link rel='edit' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/5783789060522649456'/><link rel='self' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/5783789060522649456'/><link rel='alternate' type='text/html' href='https://blog.adamcameron.me/2025/10/typescript-decorators-not-actually.html' title='TypeScript decorators: not actually decorators'/><author><name>Adam Cameron</name><uri>http://www.blogger.com/profile/04830762402027484810</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author></entry><entry><id>tag:blogger.com,1999:blog-8141574561530432909.post-8593910102377513087</id><published>2025-10-02T08:28:00.002+00:00</published><updated>2025-10-02T08:28:30.434+00:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Javascript"/><category scheme="http://www.blogger.com/atom/ns#" term="Mixins"/><category scheme="http://www.blogger.com/atom/ns#" term="TypeScript"/><title type='text'>TypeScript mixins: poor person&#39;s composition, but with generics</title><content type='html'>&lt;p&gt;G&#39;day:&lt;/p&gt;

&lt;p&gt;I&#39;ve been working through TypeScript classes, and today I hit mixins. For those unfamiliar, mixins are a pattern for composing behavior from multiple sources - think Ruby&#39;s modules or PHP&#39;s traits. They&#39;re basically &quot;poor person&#39;s composition&quot; - a way to share behavior between classes when you can&#39;t (or won&#39;t) use proper dependency injection.&lt;/p&gt;

&lt;p&gt;I think they&#39;re a terrible pattern. If I need shared behavior, I&#39;d use actual composition - create a proper class and inject it as a dependency. But I&#39;m not always working with my own code, and mixins do exist in the wild, so here we are.&lt;/p&gt;

&lt;p&gt;The &lt;a href=&quot;https://www.typescriptlang.org/docs/handbook/mixins.html&quot; target=&quot;_blank&quot;&gt;TypeScript mixin&lt;/a&gt; implementation is interesting though - it&#39;s built on generics and functions that return classes, which is quite different from the prototype-mutation approach you see in JavaScript. And despite my reservations about the pattern itself, understanding how it works turned out to be useful for understanding TypeScript&#39;s type system better.&lt;/p&gt;

&lt;h3&gt;The basic pattern&lt;/h3&gt;

&lt;p&gt;TypeScript mixins aren&#39;t about mutating prototypes at runtime (though you can do that in JavaScript). They&#39;re functions that take a class and return a new class that extends it.&lt;/p&gt;

&lt;p&gt;For this example, I wanted a mixin that would add a &lt;samp&gt;flatten()&lt;/samp&gt; method to any class - something that takes all the object&#39;s properties and concatenates their values into a single string. Not particularly useful in real code, but simple enough to demonstrate the mechanics without getting lost in business logic.&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;type Constructor = new (...args: any[]) =&gt; {}

function applyFlattening&amp;lt;TBase extends Constructor&amp;gt;(Base: TBase) {
  return class Flattener extends Base {
    flatten(): string {
      return Object.entries(this).reduce(
        (flattened: string, [_, value]): string =&gt; {
          return flattened + String(value)
        },
        &#39;&#39;
      )
    }
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/blob/0.19/src/lt-29/mixins.ts&quot; target=&quot;_blank&quot;&gt;mixins.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That &lt;samp&gt;Constructor&lt;/samp&gt; type is saying &quot;anything that can be called with &lt;samp&gt;new&lt;/samp&gt; and returns an object&quot;. The mixin function takes a class that matches this type and returns a new anonymous class that extends the base class with additional behavior.&lt;/p&gt;

&lt;p&gt;You can then apply it to any class:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export class Name {
  constructor(
    public firstName: string,
    public lastName: string
  ) {}

  get fullName(): string {
    return `${this.firstName} ${this.lastName}`
  }
}

export const FlattenableName = applyFlattening(Name)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;samp&gt;FlattenableName&lt;/samp&gt; is now a class that has everything &lt;samp&gt;Name&lt;/samp&gt; had plus the &lt;samp&gt;flatten()&lt;/samp&gt; method. TypeScript tracks all of this at compile time, so you get proper type checking and autocomplete for both the base class members and the mixin methods.&lt;/p&gt;
&lt;h3&gt;The generics bit&lt;/h3&gt;

&lt;p&gt;The confusing part (at least initially) is this bit:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;function applyFlattening&amp;lt;TBase extends Constructor&amp;gt;(Base: TBase)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Without understanding generics, this is completely opaque. The &lt;samp&gt;&amp;lt;TBase extends Constructor&amp;gt;&lt;/samp&gt; is saying &quot;this function is generic over some type &lt;samp&gt;TBase&lt;/samp&gt;, which must be a constructor&quot;. The &lt;samp&gt;Base: TBase&lt;/samp&gt; parameter then uses that type.&lt;/p&gt;

&lt;p&gt;This lets TypeScript track what specific class you&#39;re mixing into. When you call &lt;samp&gt;applyFlattening(Name)&lt;/samp&gt;, TypeScript knows that &lt;samp&gt;TBase&lt;/samp&gt; is specifically the &lt;samp&gt;Name&lt;/samp&gt; class, so it can infer that the returned class has both &lt;samp&gt;Name&lt;/samp&gt;&#39;s properties and methods plus the &lt;samp&gt;flatten()&lt;/samp&gt; method.&lt;/p&gt;

&lt;p&gt;Without generics, TypeScript would only know &quot;some constructor was passed in&quot; and couldn&#39;t give you proper type information about what the resulting class actually contains. The generic parameter preserves the type information through the composition.&lt;/p&gt;

&lt;p&gt;I hadn&#39;t covered generics properly before hitting this (it&#39;s still on my todo list), which made the mixin syntax particularly cryptic. But the core concept is straightforward once you understand that generics are about preserving type information as you transform data - in this case, transforming a class into an extended version of itself.&lt;/p&gt;


&lt;h3&gt;Using the mixed class&lt;/h3&gt;

&lt;p&gt;Once you&#39;ve got the mixed class, using it is straightforward:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const flattenableName: InstanceType&amp;lt;typeof FlattenableName&amp;gt; =
  new FlattenableName(&#39;Zachary&#39;, &#39;Lynch&#39;)
expect(flattenableName.fullName).toEqual(&#39;Zachary Lynch&#39;)

const flattenedName: string = flattenableName.flatten()
expect(flattenedName).toEqual(&#39;ZacharyLynch&#39;)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/blob/0.19/tests/lt-29/mixins.test.ts&quot; target=&quot;_blank&quot;&gt;mixins.test.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;samp&gt;InstanceType&amp;lt;typeof FlattenableName&amp;gt;&lt;/samp&gt; bit is necessary because &lt;samp&gt;FlattenableName&lt;/samp&gt; is a value (the constructor function), not a type. &lt;samp&gt;typeof FlattenableName&lt;/samp&gt; gives you the constructor type, and &lt;samp&gt;InstanceType&amp;lt;...&amp;gt;&lt;/samp&gt; extracts the type of instances that constructor creates.&lt;/p&gt;

&lt;p&gt;Once you&#39;ve got an instance, it has both the original &lt;samp&gt;Name&lt;/samp&gt; functionality (the &lt;samp&gt;fullName&lt;/samp&gt; getter) and the new &lt;samp&gt;flatten()&lt;/samp&gt; method. The mixin has full access to &lt;samp&gt;this&lt;/samp&gt;, so it can see all the object&#39;s properties - in this case, &lt;samp&gt;firstName&lt;/samp&gt; and &lt;samp&gt;lastName&lt;/samp&gt;.&lt;/p&gt;

&lt;h3&gt;Constraining the mixin&lt;/h3&gt;

&lt;p&gt;The basic &lt;samp&gt;Constructor&lt;/samp&gt; type accepts any class - it doesn&#39;t care what properties or methods the class has. But you can constrain mixins to only work with classes that have specific properties:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;type NameConstructor = new (
  ...args: any[]
) =&gt; {
  firstName: string
  lastName: string
}

function applyNameFlattening&amp;lt;TBase extends NameConstructor&amp;gt;(Base: TBase) {
  return class NameFlattener extends Base {
    flatten(): string {
      return this.firstName + this.lastName
    }
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/blob/0.19/src/lt-29/mixins.ts&quot; target=&quot;_blank&quot;&gt;mixins.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;samp&gt;NameConstructor&lt;/samp&gt; type specifies that the resulting instance must have &lt;samp&gt;firstName&lt;/samp&gt; and &lt;samp&gt;lastName&lt;/samp&gt; properties. Now the mixin can safely access those properties directly - TypeScript knows they&#39;ll exist.&lt;/p&gt;

&lt;p&gt;You can&#39;t constrain the constructor parameters themselves - that &lt;samp&gt;...args: any[]&lt;/samp&gt; is mandatory for mixin functions. TypeScript requires this because the mixin doesn&#39;t know what arguments the base class constructor needs. You can only constrain the instance type (the return type of the constructor).&lt;/p&gt;

&lt;p&gt;This means a class like this won&#39;t work with the constrained mixin:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export class ShortName {
  constructor(public firstName: string) {}
}
// This won&#39;t compile:
// export const FlattenableShortName = applyNameFlattening(ShortName)
// Argument of type &#39;typeof ShortName&#39; is not assignable to parameter of type &#39;NameConstructor&#39;&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;TypeScript correctly rejects it because &lt;samp&gt;ShortName&lt;/samp&gt; doesn&#39;t have a &lt;samp&gt;lastName&lt;/samp&gt; property, and the mixin&#39;s &lt;samp&gt;flatten()&lt;/samp&gt; method needs it.&lt;/p&gt;

&lt;h3&gt;Chaining multiple mixins&lt;/h3&gt;

&lt;p&gt;You can apply multiple mixins by chaining them - pass the result of one mixin into another:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;function applyArrayifier&amp;lt;TBase extends Constructor&amp;gt;(Base: TBase) {
  return class Arrayifier extends Base {
    arrayify(): string[] {
      return Object.entries(this).reduce(
        (arrayified: string[], [_, value]): string[] =&gt; {
          return arrayified.concat(String(value).split(&#39;&#39;))
        },
        []
      )
    }
  }
}

export const ArrayableFlattenableName = applyArrayifier(FlattenableName)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/blob/0.19/src/lt-29/mixins.ts&quot; target=&quot;_blank&quot;&gt;mixins.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now &lt;samp&gt;ArrayableFlattenableName&lt;/samp&gt; has everything from &lt;samp&gt;Name&lt;/samp&gt;, plus &lt;samp&gt;flatten()&lt;/samp&gt; from the first mixin, plus &lt;samp&gt;arrayify()&lt;/samp&gt; from the second mixin:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const transformableName: InstanceType&amp;lt;typeof ArrayableFlattenableName&amp;gt; =
  new ArrayableFlattenableName(&#39;Zachary&#39;, &#39;Lynch&#39;)
expect(transformableName.fullName).toEqual(&#39;Zachary Lynch&#39;)

const flattenedName: string = transformableName.flatten()
expect(flattenedName).toEqual(&#39;ZacharyLynch&#39;)

const arrayifiedName: string[] = transformableName.arrayify()
expect(arrayifiedName).toEqual(&#39;ZacharyLynch&#39;.split(&#39;&#39;))&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/blob/0.19/tests/lt-29/mixins.test.ts&quot; target=&quot;_blank&quot;&gt;mixins.test.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TypeScript correctly infers that all three sets of functionality are available on the final class. The type information flows through each composition step.&lt;/p&gt;

&lt;h3&gt;Why not just use composition?&lt;/h3&gt;

&lt;p&gt;Right, so having learned how mixins work in TypeScript, I still think they&#39;re a poor choice for most situations. If you need shared behavior, use actual composition:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;class Flattener {
  flatten(obj: Record&amp;lt;string, unknown&amp;gt;): string {
    return Object.entries(obj).reduce(
      (flattened, [_, value]) =&gt; flattened + String(value),
      &#39;&#39;
    )
  }
}

class Name {
  constructor(
    public firstName: string,
    public lastName: string,
    private flattener: Flattener
  ) {}
  
  flatten(): string {
    return this.flattener.flatten(this)
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This is clearer about dependencies, easier to test (inject a mock &lt;samp&gt;Flattener&lt;/samp&gt;), and doesn&#39;t require understanding generics or the mixin pattern. The behavior is in a separate class that can be reused anywhere, not just through inheritance chains.&lt;/p&gt;

&lt;p&gt;Mixins make sense in languages where you genuinely can&#39;t do proper composition easily, or where the inheritance model is the primary abstraction. But TypeScript has first-class support for dependency injection and composition. Use it.&lt;/p&gt;

&lt;p&gt;The main legitimate use case I can see for TypeScript mixins is when you&#39;re working with existing code that uses them, or when you need to add behavior to classes you don&#39;t control. Otherwise, favor composition.&lt;/p&gt;

&lt;h3&gt;The abstract class limitation&lt;/h3&gt;

&lt;p&gt;One thing you can&#39;t do with mixins is apply them to abstract classes. The pattern requires using &lt;samp&gt;new Base(...)&lt;/samp&gt; to instantiate and extend the base class, but abstract classes can&#39;t be instantiated - that&#39;s their whole point.&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;abstract class AbstractBase {
  abstract doSomething(): void
}

// This won&#39;t work:
// const Mixed = applyMixin(AbstractBase)
// Cannot create an instance of an abstract class&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The workarounds involve either making the base class concrete (which defeats the purpose of having it abstract), or mixing into a concrete subclass instead of the abstract parent. Neither is particularly satisfying.&lt;/p&gt;

&lt;p&gt;This is a fundamental incompatibility between &quot;can&#39;t instantiate&quot; (abstract classes) and &quot;must instantiate to extend&quot; (the mixin pattern). It&#39;s another reason to prefer composition - you can absolutely inject abstract dependencies through constructor parameters without these limitations.&lt;/p&gt;

&lt;h3&gt;What I learned&lt;/h3&gt;

&lt;p&gt;TypeScript mixins are functions that take classes and return extended classes. They use generics to preserve type information through the composition, and TypeScript tracks everything at compile time so you get proper type checking.&lt;/p&gt;

&lt;p&gt;The syntax is more complicated than it needs to be (that &lt;samp&gt;type Constructor = new (...args: any[]) =&amp;gt; {}&lt;/samp&gt; bit), and you need to understand generics before any of it makes sense. The &lt;samp&gt;InstanceType&amp;lt;typeof ClassName&amp;gt;&lt;/samp&gt; dance is necessary because of how TypeScript distinguishes between constructor types and instance types.&lt;/p&gt;

&lt;p&gt;You can constrain mixins to only work with classes that have specific properties, and you can chain multiple mixins together. But you can&#39;t use them with abstract classes, and they&#39;re generally a worse choice than proper composition for most real-world scenarios.&lt;/p&gt;

&lt;p&gt;I learned the pattern because I&#39;ll encounter it in other people&#39;s code, not because I plan to use it myself. If I need shared behavior, I&#39;ll use dependency injection and composition like a sensible person. But now at least I understand what&#39;s happening when I see &lt;samp&gt;const MixedClass = applyMixin(BaseClass)&lt;/samp&gt; in a codebase.&lt;/p&gt;

&lt;p&gt;The full code for this investigation is in my &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.19/src/lt-29&quot; target=&quot;_blank&quot;&gt;learning-typescript repository&lt;/a&gt;. Thanks to &lt;a href=&quot;https://claude.ai/&quot; target=&quot;_blank&quot;&gt;Claudia&lt;/a&gt; for helping work through the type constraints and the abstract class limitation, and for assistance with this write-up.&lt;/p&gt;

&lt;p&gt;Righto.&lt;/p&gt;

&lt;p&gt;--&lt;br&gt;Adam&lt;/p&gt;
</content><link rel='edit' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/8593910102377513087'/><link rel='self' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/8593910102377513087'/><link rel='alternate' type='text/html' href='https://blog.adamcameron.me/2025/10/typescript-mixins-poor-persons.html' title='TypeScript mixins: poor person&#39;s composition, but with generics'/><author><name>Adam Cameron</name><uri>http://www.blogger.com/profile/04830762402027484810</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author></entry><entry><id>tag:blogger.com,1999:blog-8141574561530432909.post-5895233342103563620</id><published>2025-09-30T20:57:00.002+00:00</published><updated>2025-09-30T20:57:26.772+00:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Javascript"/><category scheme="http://www.blogger.com/atom/ns#" term="TypeScript"/><title type='text'>TypeScript constructor overloading: when one implementation has to handle multiple signatures</title><content type='html'>&lt;p&gt;G&#39;day:&lt;/p&gt;

&lt;p&gt;I&#39;ve been working through TypeScript classes, and today I hit constructor overloading. Coming from PHP where you can&#39;t overload constructors at all (you get one constructor, that&#39;s it), the TypeScript approach seemed straightforward enough: declare multiple signatures, implement once, job done.&lt;/p&gt;

&lt;p&gt;Turns out the &quot;implement once&quot; bit is where things get interesting.&lt;/p&gt;

&lt;h3&gt;The basic pattern&lt;/h3&gt;

&lt;p&gt;TypeScript lets you declare multiple constructor signatures followed by a single implementation:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;constructor()
constructor(s: string)
constructor(n: number)
constructor(s: string, n: number)
constructor(p1?: string | number, p2?: number) {
  // implementation handles all four cases
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The first four lines are just declarations - they tell TypeScript &quot;these are the valid ways to call this constructor&quot;. The final signature is the actual implementation that has to handle all of them.&lt;/p&gt;

&lt;p&gt;Simple enough when you&#39;ve got a no-arg constructor and a two-arg constructor - those are clearly different. But what happens when you need two different single-argument constructors, one taking a string and one taking a number?&lt;/p&gt;

&lt;p&gt;That&#39;s where I got stuck.&lt;/p&gt;

&lt;h2&gt;The implementation signature problem&lt;/h2&gt;

&lt;p&gt;Here&#39;s what I wanted to support:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const empty = new Numeric()                    // both properties null
const justString = new Numeric(&#39;forty-two&#39;)    // asString set, asNumeric null
const justNumber = new Numeric(42)             // asNumeric set, asString null
const both = new Numeric(&#39;forty-two&#39;, 42)      // both properties set&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/blob/0.13/tests/lt-22/constructors.test.ts&quot; target=&quot;_blank&quot;&gt;constructors.test.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;My first attempt at the implementation looked like this:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;constructor()
constructor(s: string)
constructor(s: string, n: number)
constructor(s?: string, n?: number) {
  this.asString = s ?? null
  this.asNumeric = n ?? null
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Works fine for the no-arg, single-string, and two-arg cases. But then I needed to add the single-number constructor:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;constructor(n: number)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And suddenly the compiler wasn&#39;t happy: &quot;This overload signature is not compatible with its implementation signature.&quot;&lt;/p&gt;

&lt;p&gt;The error pointed at the new overload, but the actual problem was in the implementation. It took me ages (and asking &lt;a href=&quot;https://claude.ai/&quot; target=&quot;_blank&quot;&gt;Claudia&lt;/a&gt;) to work this out. This is entirely down to me not reading, but just looking at what line it was pointing too. Duh. The first parameter was typed as &lt;samp&gt;string&lt;/samp&gt; (or &lt;samp&gt;undefined&lt;/samp&gt;), but the new overload promised it could also be a &lt;samp&gt;number&lt;/samp&gt;. The implementation couldn&#39;t deliver on what the overload signature was promising.&lt;/p&gt;

&lt;h3&gt;Why neutral parameter names matter&lt;/h3&gt;

&lt;p&gt;The fix was to change the implementation signature to accept both types:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;constructor(p1?: string | number, p2?: number) {
  // ...
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;But here&#39;s where the parameter naming became important. My initial instinct was to keep using meaningful names like &lt;samp&gt;s&lt;/samp&gt; and &lt;samp&gt;n&lt;/samp&gt;:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;constructor(s?: string | number, n?: number)&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This felt wrong. When you&#39;re reading the implementation code and you see a parameter called &lt;samp&gt;s&lt;/samp&gt;, you expect it to be a string. But now it might be a number. The name actively misleads you about what the parameter contains.&lt;/p&gt;

&lt;p&gt;Switching to neutral names like &lt;samp&gt;p1&lt;/samp&gt; and &lt;samp&gt;p2&lt;/samp&gt; made the implementation logic much clearer - these are just &quot;parameter slots&quot; that could contain different types depending on which overload was called. No assumptions about what they contain.&lt;/p&gt;

&lt;h3&gt;Runtime type checking&lt;/h3&gt;

&lt;p&gt;Once the implementation signature accepts both types, you need runtime logic to figure out which overload was actually called:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;constructor(p1?: string | number, p2?: number) {
  if (typeof p1 === &#39;number&#39; &amp;&amp; p2 === undefined) {
    this.asNumeric = p1
    return
  }
  this.asString = (p1 as string) ?? null
  this.asNumeric = p2 ?? null
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/blob/0.13/src/lt-22/constructors.ts&quot; target=&quot;_blank&quot;&gt;constructors.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first check handles the single-number case: if the first parameter is a number and there&#39;s no second parameter, we&#39;re dealing with &lt;samp&gt;new Numeric(42)&lt;/samp&gt;. Set &lt;samp&gt;asNumeric&lt;/samp&gt; and bail out.&lt;/p&gt;

&lt;p&gt;Everything else falls through to the default logic: treat the first parameter as a string (or absent) and the second parameter as a number (or absent). This covers the no-arg, single-string, and two-arg cases.&lt;/p&gt;

&lt;p&gt;The type assertion &lt;samp&gt;(p1 as string)&lt;/samp&gt; is necessary because TypeScript can&#39;t prove that &lt;samp&gt;p1&lt;/samp&gt; is a string at that point - we&#39;ve only eliminated the case where it&#39;s definitely a number. From the compiler&#39;s perspective, it could still be &lt;samp&gt;string | number | undefined&lt;/samp&gt;.&lt;/p&gt;

&lt;h3&gt;The bug I didn&#39;t notice&lt;/h3&gt;

&lt;p&gt;I had the implementation working and all my tests passing. Job done, right? Except when I submitted the PR, GitHub Copilot&#39;s review flagged this:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;this.asString = (p1 as string) || null
this.asNumeric = p2 || null&lt;/code&gt;&lt;/pre&gt;

&lt;blockquote&gt;The logic for handling empty strings is incorrect. An empty string (&#39;&#39;) will be converted to null due to the || operator, but empty strings should be preserved as valid string values. Use nullish coalescing (??) instead or explicit null checks.&lt;/blockquote&gt;

&lt;p&gt;Copilot was absolutely right. The &lt;samp&gt;||&lt;/samp&gt; operator treats all falsy values as &quot;use the right-hand side&quot;, which includes:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;samp&gt;&#39;&#39;&lt;/samp&gt; (empty string)&lt;/li&gt;
  &lt;li&gt;&lt;samp&gt;0&lt;/samp&gt; (zero)&lt;/li&gt;
  &lt;li&gt;&lt;samp&gt;false&lt;/samp&gt;&lt;/li&gt;
  &lt;li&gt;&lt;samp&gt;null&lt;/samp&gt;&lt;/li&gt;
  &lt;li&gt;&lt;samp&gt;undefined&lt;/samp&gt;&lt;/li&gt;
  &lt;li&gt;&lt;samp&gt;NaN&lt;/samp&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;samp&gt;new Numeric(&#39;&#39;)&lt;/samp&gt; would set &lt;samp&gt;asString&lt;/samp&gt; to &lt;samp&gt;null&lt;/samp&gt; instead of &lt;samp&gt;&#39;&#39;&lt;/samp&gt;, and &lt;samp&gt;new Numeric(&#39;test&#39;, 0)&lt;/samp&gt; would set &lt;samp&gt;asNumeric&lt;/samp&gt; to &lt;samp&gt;null&lt;/samp&gt; instead of &lt;samp&gt;0&lt;/samp&gt;. Both are perfectly valid values that the constructor should accept.&lt;/p&gt;

&lt;p&gt;The &lt;samp&gt;??&lt;/samp&gt; (nullish coalescing) operator only treats &lt;samp&gt;null&lt;/samp&gt; and &lt;samp&gt;undefined&lt;/samp&gt; as &quot;use the right-hand side&quot;, which is exactly what I needed:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;this.asString = (p1 as string) ?? null
this.asNumeric = p2 ?? null&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now empty strings and zeros are preserved as valid values.&lt;/p&gt;

&lt;h3&gt;Testing the edge cases&lt;/h3&gt;

&lt;p&gt;The fact that this bug existed meant my initial tests weren&#39;t comprehensive enough. I&#39;d tested the basic cases but missed the edge cases where valid values happen to be falsy.&lt;/p&gt;

&lt;p&gt;I added tests for empty strings and zeros:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;it(&#39;accepts an empty string as the only argument&#39;, () =&gt; {
  const o: Numeric = new Numeric(&#39;&#39;)

  expect(o.asString).toEqual(&#39;&#39;)
  expect(o.asNumeric).toBeNull()
})

it(&#39;accepts zero as the only argument&#39;, () =&gt; {
  const o: Numeric = new Numeric(0)

  expect(o.asNumeric).toEqual(0)
  expect(o.asString).toBeNull()
})

it(&#39;accepts an empty string as the first argument&#39;, () =&gt; {
  const o: Numeric = new Numeric(&#39;&#39;, -1)

  expect(o.asString).toEqual(&#39;&#39;)
})

it(&#39;accepts zero as the second argument&#39;, () =&gt; {
  const o: Numeric = new Numeric(&#39;NOT_TESTED&#39;, 0)

  expect(o.asNumeric).toEqual(0)
})&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/blob/0.13/tests/lt-22/constructors.test.ts&quot; target=&quot;_blank&quot;&gt;constructors.test.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;With the original &lt;samp&gt;||&lt;/samp&gt; implementation, all four of these tests failed. After switching to &lt;samp&gt;??&lt;/samp&gt;, they all passed. That&#39;s how testing is supposed to work - the tests catch the bug, you fix it, the tests confirm the fix.&lt;/p&gt;

&lt;p&gt;Fair play to Copilot for spotting this in the PR review. It&#39;s easy to miss falsy edge cases when you&#39;re focused on getting the type signatures right.&lt;/p&gt;

&lt;h3&gt;Method overloading in general&lt;/h3&gt;

&lt;p&gt;Worth noting that constructor overloading is just a specific case of method overloading. Any method can use this same pattern of multiple signatures with one implementation:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;class Example {
  doThing(): void
  doThing(s: string): void
  doThing(n: number): void
  doThing(p?: string | number): void {
    // implementation handles all cases
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The same principles apply: the implementation signature needs to be flexible enough to handle all the declared overloads, and you need runtime type checking to figure out which overload was actually called.&lt;/p&gt;

&lt;p&gt;Constructors just happen to be where I first encountered this pattern, because that&#39;s where you often want multiple ways to initialize an object with different combinations of parameters.&lt;/p&gt;

&lt;h2&gt;What I learned&lt;/h2&gt;

&lt;p&gt;Constructor overloading in TypeScript is straightforward once you understand that the implementation signature has to be a superset of all the overload signatures. The tricky bit is when you have overloads that look similar but take different types - that&#39;s when you need union types and runtime type checking to make it work.&lt;/p&gt;

&lt;p&gt;Using neutral parameter names in the implementation helps avoid confusion about what types you&#39;re actually dealing with. And edge case testing matters - falsy values like empty strings and zeros are valid inputs that need explicit test coverage.&lt;/p&gt;

&lt;p&gt;The full code is in my &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.13/src/lt-22&quot; target=&quot;_blank&quot;&gt;learning-typescript repository&lt;/a&gt; if you want to see the complete implementation. Thanks to Claudia for helping me understand why that compilation error was pointing at the overload when the problem was in the implementation, and to GitHub Copilot for catching the &lt;samp&gt;||&lt;/samp&gt; vs &lt;samp&gt;??&lt;/samp&gt; bug in the PR review.&lt;/p&gt;

&lt;p&gt;Righto.&lt;/p&gt;

&lt;p&gt;--&lt;br&gt;Adam&lt;/p&gt;



















</content><link rel='edit' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/5895233342103563620'/><link rel='self' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/5895233342103563620'/><link rel='alternate' type='text/html' href='https://blog.adamcameron.me/2025/09/typescript-constructor-overloading-when.html' title='TypeScript constructor overloading: when one implementation has to handle multiple signatures'/><author><name>Adam Cameron</name><uri>http://www.blogger.com/profile/04830762402027484810</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author></entry><entry><id>tag:blogger.com,1999:blog-8141574561530432909.post-1213060986551010604</id><published>2025-09-29T20:36:00.005+00:00</published><updated>2025-09-30T08:43:31.625+00:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Javascript"/><category scheme="http://www.blogger.com/atom/ns#" term="TypeScript"/><title type='text'>TypeScript late static binding: parameters that aren&#39;t actually parameters</title><content type='html'>&lt;p&gt;G&#39;day:&lt;/p&gt;

&lt;p&gt;I&#39;ve been working through classes in TypeScript as part of my learning project, and today I hit static methods. Coming from PHP, one of the first questions that popped into my head was &quot;how does late static binding work here?&quot;&lt;/p&gt;

&lt;p&gt;In PHP, you can do this:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;class Base {
    static function create() {
        return new static();  // Creates instance of the actual called class
    }
}

class Child extends Base {}

$instance = Child::create();  // Returns a Child instance, not Base&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The &lt;samp&gt;static&lt;/samp&gt; keyword in &lt;samp&gt;new static()&lt;/samp&gt; means &quot;whatever class this method was actually called on&quot;, not &quot;the class where this method is defined&quot;. It&#39;s late binding - the class is resolved at runtime based on how the method was called.&lt;/p&gt;

&lt;p&gt;Seemed like a reasonable thing to want in TypeScript. Turns out it&#39;s possible, but the syntax is... questionable.&lt;/p&gt;

&lt;h3&gt;The TypeScript approach&lt;/h3&gt;

&lt;p&gt;Here&#39;s what I ended up with:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export class TranslatedNumber {
  constructor(
    private value: number,
    private en: string,
    private mi: string
  ) {}

  getAll(): { value: number; en: string; mi: string } {
    return {
      value: this.value,
      en: this.en,
      mi: this.mi,
    }
  }

  static fromTuple&amp;lt;T extends typeof TranslatedNumber&amp;gt;(
    &lt;span class=&quot;xr xrt&quot; data-index=&quot;this-t&quot;&gt;this: T,&lt;/span&gt;
    values: [value: number, en: string, mi: string]
  ): InstanceType&amp;lt;T&amp;gt; {
    return new this(...values) as InstanceType&amp;lt;T&amp;gt;
  }
}

export class ShoutyTranslatedNumber extends TranslatedNumber {
  constructor(value: number, en: string, mi: string) {
    super(value, en.toUpperCase(), mi.toUpperCase())
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/blob/0.9/src/lt-21/static.ts&quot; target=&quot;_blank&quot;&gt;static.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;And it works - when you call &lt;samp&gt;ShoutyTranslatedNumber.fromTuple()&lt;/samp&gt;, you get a &lt;samp&gt;ShoutyTranslatedNumber&lt;/samp&gt; instance back, not a &lt;samp&gt;TranslatedNumber&lt;/samp&gt;:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const translated = ShoutyTranslatedNumber.fromTuple([3, &#39;three&#39;, &#39;toru&#39;])

expect(translated.getAll()).toEqual({
  value: 3,
  en: &#39;THREE&#39;,
  mi: &#39;TORU&#39;,
})&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/blob/0.9/tests/lt-21/static.test.ts&quot; target=&quot;_blank&quot;&gt;static.test.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The late binding works. But look at that &lt;samp&gt;fromTuple&lt;/samp&gt; method signature again. Specifically this bit: &lt;samp class=&quot;xr xrd underline&quot; data-index=&quot;this-t&quot;&gt;this: T&lt;/samp&gt;.&lt;/p&gt;


&lt;h3&gt;Parameters that aren&#39;t parameters&lt;/h3&gt;

&lt;p&gt;When I first saw &lt;samp&gt;this: T&lt;/samp&gt; in the parameter list, my immediate reaction was &quot;okay, so I need to pass the class as the first argument?&quot;&lt;/p&gt;

&lt;p&gt;But the usage doesn&#39;t have any extra parameter:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const translated = ShoutyTranslatedNumber.fromTuple([3, &#39;three&#39;, &#39;toru&#39;])&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;No class being passed. Just the tuple. So what the hell is &lt;samp&gt;&lt;span class=&quot;xr xrt&quot; data-index=&quot;this-t&quot;&gt;this: T,&lt;/span&gt;&lt;/samp&gt; doing in the parameter list?&lt;/p&gt;

&lt;p&gt;Turns out it&#39;s a TypeScript-specific construct that exists purely for the type system. It&#39;s not a runtime parameter at all - it gets completely erased during compilation. It&#39;s a type hint that tells TypeScript &quot;remember which class this static method was called on&quot;.&lt;/p&gt;

&lt;p&gt;When you write &lt;samp&gt;ShoutyTranslatedNumber.fromTuple([3, &#39;three&#39;, &#39;toru&#39;])&lt;/samp&gt;, TypeScript infers:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The &lt;samp&gt;this&lt;/samp&gt; inside &lt;samp&gt;fromTuple&lt;/samp&gt; refers to &lt;samp&gt;ShoutyTranslatedNumber&lt;/samp&gt;&lt;/li&gt;
  &lt;li&gt;Therefore &lt;samp&gt;T&lt;/samp&gt; is &lt;samp&gt;typeof ShoutyTranslatedNumber&lt;/samp&gt;&lt;/li&gt;
  &lt;li&gt;Therefore &lt;samp&gt;InstanceType&amp;lt;T&amp;gt;&lt;/samp&gt; is &lt;samp&gt;ShoutyTranslatedNumber&lt;/samp&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It&#39;s clever. It works. But it&#39;s also completely bizarre if you&#39;re coming from any language where parameters are just parameters.&lt;/p&gt;

&lt;h3&gt;Why this feels wrong&lt;/h3&gt;

&lt;p&gt;The thing that bothers me about this isn&#39;t that it doesn&#39;t work - it does work fine. It&#39;s that the solution is a hack at the type system level when it should be a language feature.&lt;/p&gt;

&lt;p&gt;TypeScript could have introduced syntax like &lt;samp&gt;new static()&lt;/samp&gt; or &lt;samp&gt;new this()&lt;/samp&gt; and compiled it to whatever JavaScript pattern makes it work at runtime. Instead, they&#39;ve made developers express &quot;the class this method was called on&quot; through a phantom parameter that only exists for the type checker.&lt;/p&gt;

&lt;p&gt;Compare this to how other languages handle it:&lt;/p&gt;

&lt;p&gt;PHP just gives you &lt;samp&gt;static&lt;/samp&gt; as a keyword. You write &lt;samp&gt;new static()&lt;/samp&gt; and the compiler handles the rest.&lt;/p&gt;

&lt;p&gt;Kotlin compiles to JavaScript too, but when you write Kotlin, you write actual Kotlin - proper classes, sealed classes, data classes, all the language features. The compiler figures out how to make it work in JavaScript. You don&#39;t write weird pseudo-parameters because &quot;JavaScript doesn&#39;t have that feature&quot;.&lt;/p&gt;

&lt;p&gt;TypeScript has positioned itself as &quot;JavaScript with types&quot; rather than &quot;a language that compiles to JavaScript&quot;, which means it&#39;s constantly constrained by JavaScript&#39;s limitations instead of abstracting them away. When JavaScript doesn&#39;t have a concept, TypeScript makes you do the workaround instead of the compiler doing it.&lt;/p&gt;

&lt;p&gt;It&#39;s functional, but it&#39;s not elegant. And it&#39;s definitely not intuitive.&lt;/p&gt;

&lt;h3&gt;Does it matter?&lt;/h3&gt;

&lt;p&gt;In practice? Not really. Once you know the pattern, it&#39;s straightforward enough to use. The &lt;samp class=&quot;xr xrd underline&quot; data-index=&quot;this-t&quot;&gt;this: T&lt;/samp&gt; parameter becomes just another TypeScript idiom you memorise and move on.&lt;/p&gt;

&lt;p&gt;But it does highlight a fundamental tension in TypeScript&#39;s design philosophy. The language is scared to be a proper language with its own features and syntax. Everything has to map cleanly back to JavaScript, even when that makes the developer experience worse.&lt;/p&gt;

&lt;p&gt;I found &lt;a href=&quot;https://stackoverflow.com/a/42768627/894061&quot; target=&quot;_blank&quot;&gt;this Stack Overflow answer&lt;/a&gt; while researching this, which explains the mechanics well enough, but doesn&#39;t really acknowledge how weird the solution is. It&#39;s all type theory without much &quot;here&#39;s why the language works this way&quot;.&lt;/p&gt;

&lt;p&gt;For now, I&#39;ve got late static binding working in TypeScript. It required some generics gymnastics and a phantom parameter, but it does what I need. I&#39;ll probably dig deeper into generics in a future ticket - there&#39;s clearly more to understand there, and I&#39;ve not worked with generics in any language before, so that&#39;ll be interesting.&lt;/p&gt;

&lt;p&gt;The code for this is in my &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/blob/0.9/src/lt-21/&quot; target=&quot;_blank&quot;&gt;learning-typescript repository&lt;/a&gt; if you want to see the full implementation. Thanks to &lt;a href=&quot;https://claude.ai/&quot; target=&quot;_blank&quot;&gt;Claudia&lt;/a&gt; for helping me understand what the hell &lt;samp&gt;this: T&lt;/samp&gt; was actually doing and for assistance with this write-up.&lt;/p&gt;

&lt;p&gt;Righto.&lt;/p&gt;

&lt;p&gt;-- &lt;br&gt;Adam&lt;/p&gt;
</content><link rel='edit' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/1213060986551010604'/><link rel='self' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/1213060986551010604'/><link rel='alternate' type='text/html' href='https://blog.adamcameron.me/2025/09/typescript-late-static-binding.html' title='TypeScript late static binding: parameters that aren&#39;t actually parameters'/><author><name>Adam Cameron</name><uri>http://www.blogger.com/profile/04830762402027484810</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author></entry><entry><id>tag:blogger.com,1999:blog-8141574561530432909.post-1148283142857690014</id><published>2025-09-27T10:28:00.002+00:00</published><updated>2025-09-27T10:28:19.118+00:00</updated><category scheme="http://www.blogger.com/atom/ns#" term="Javascript"/><category scheme="http://www.blogger.com/atom/ns#" term="TypeScript"/><title type='text'>JavaScript Symbols: when learning one thing teaches you fifteen others</title><content type='html'>&lt;p&gt;G&#39;day:&lt;/p&gt;

&lt;p&gt;This is one of those &quot;I thought I was learning one thing but ended up discovering fifteen other weird JavaScript behaviors&quot; situations that seems to happen every time I try to understand a JavaScript feature properly.&lt;/p&gt;

&lt;p&gt;I was working through my TypeScript learning project, specifically tackling symbols (&lt;a href=&quot;https://www.typescriptlang.org/docs/handbook/symbols.html#symboltoprimitive&quot; target=&quot;_blank&quot;&gt;TS&lt;/a&gt; / &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toPrimitive&quot; target=&quot;_blank&quot;&gt;JS&lt;/a&gt;) as part of understanding primitive types. Seemed straightforward enough - symbols are unique primitive values, used for creating &quot;private&quot; object properties and implementing well-known protocols. Easy, right?&lt;/p&gt;

&lt;p&gt;Wrong. What started as &quot;symbols are just unique identifiers&quot; quickly turned into a masterclass in JavaScript&#39;s most bizarre type coercion behaviors, ESLint&#39;s opinions about legitimate code patterns, and why semicolons sometimes matter more than you think.&lt;/p&gt; 

&lt;h2&gt;The basics (that aren&#39;t actually basic)&lt;/h2&gt;

&lt;p&gt;Symbols are primitive values that are guaranteed to be unique:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const s1 = Symbol();
const s2 = Symbol();
console.log(s1 === s2); // false - always unique&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Except when they&#39;re not unique, because &lt;samp&gt;Symbol.for()&lt;/samp&gt; maintains a global registry:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const s1 = Symbol.for(&#39;my-key&#39;);
const s2 = Symbol.for(&#39;my-key&#39;);
console.log(s1 === s2); // true - same symbol from registry&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Fair enough. And you can&#39;t call Symbol as a constructor (unlike literally every other primitive wrapper):&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const sym = new Symbol(); // TypeError: Symbol is not a constructor&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This seemed like a reasonable safety feature until I tried to test it and discovered that TypeScript will happily let you write this nonsense, but ESLint immediately starts complaining about the &lt;samp&gt;any&lt;/samp&gt; casting required to make it &quot;work&quot;.&lt;/p&gt; &lt;h2&gt;Where things get properly weird&lt;/h2&gt; &lt;p&gt;The real fun starts when you encounter the well-known symbols - particularly &lt;a href=&quot;https://www.typescriptlang.org/docs/handbook/symbols.html#symboltoprimitive&quot; target=&quot;_blank&quot;&gt;&lt;samp&gt;Symbol.toPrimitive&lt;/samp&gt;&lt;/a&gt;. This lets you control how objects get converted to primitive values, which sounds useful until you actually try to use it.&lt;/p&gt;

&lt;p&gt;Here&#39;s a class that implements custom primitive conversion:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export class SomeClass {
  [Symbol.toPrimitive](hint: string) {
    if (hint === &#39;number&#39;) {
      return 42;
    }
    if (hint === &#39;string&#39;) {
      return &#39;forty-two&#39;;
    }
    return &#39;default&#39;;
  }
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;(from &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.5/src/lt-11/symbols.ts&quot; target=&quot;_blank&quot;&gt;symbols.ts&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now, which conversion do you think &lt;samp&gt;obj + &#39;&#39;&lt;/samp&gt; would trigger? If you guessed &quot;string&quot;, because you&#39;re concatenating with a string, you&#39;d be wrong. It actually triggers the &quot;default&quot; hint because JavaScript&#39;s &lt;samp&gt;+&lt;/samp&gt; operator is fundamentally broken.&lt;/p&gt;

&lt;p&gt;The &lt;samp&gt;+&lt;/samp&gt; operator with mixed types calls &lt;samp&gt;toPrimitive&lt;/samp&gt; with hint &lt;samp&gt;&quot;default&quot;&lt;/samp&gt;, not &lt;samp&gt;&quot;string&quot;&lt;/samp&gt;. JavaScript has to decide whether this is addition or concatenation &lt;em&gt;before&lt;/em&gt; converting the operands, so it plays it safe with the default hint. Only explicit string conversion like &lt;samp&gt;String(obj)&lt;/samp&gt; or template literals get the string hint.&lt;/p&gt;

&lt;p&gt;This is the kind of language design decision that makes you question whether the people who created JavaScript have ever actually used JavaScript.&lt;/p&gt; &lt;h2&gt;ESLint vs. reality&lt;/h2&gt; &lt;p&gt;Speaking of questionable decisions, try writing the template literal version:&lt;/p&gt; &lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;expect(`${obj}`).toBe(&#39;forty-two&#39;);&lt;/code&gt;&lt;/pre&gt; &lt;p&gt;ESLint immediately complains: &quot;Invalid type of template literal expression&quot;. It sees a custom class being used in string interpolation and assumes you&#39;ve made a mistake, despite this being exactly what &lt;samp&gt;Symbol.toPrimitive&lt;/samp&gt; is designed for.&lt;/p&gt;

&lt;p&gt;You end up with this choice:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;Suppress the ESLint rule for legitimate symbol behavior&lt;/li&gt;
  &lt;li&gt;Use &lt;samp&gt;String(obj)&lt;/samp&gt; explicitly (which actually works better anyway)&lt;/li&gt;
  &lt;li&gt;Cast to &lt;samp&gt;any&lt;/samp&gt; and deal with ESLint complaining about that instead&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Modern tooling is supposedly designed to help us write better code, but it turns out &quot;better&quot; doesn&#39;t include using JavaScript&#39;s actual primitive conversion protocols.&lt;/p&gt;

&lt;h2&gt;Symbols as &quot;secret&quot; properties&lt;/h2&gt;

&lt;p&gt;The privacy model for symbols is... interesting. They&#39;re hidden from normal enumeration but completely discoverable if you know where to look:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;const secret1 = Symbol(&#39;secret1&#39;);
const secret2 = Symbol(&#39;secret2&#39;);

const obj = {
  publicProp: &#39;visible&#39;,
  [secret1]: &#39;hidden&#39;,
  [secret2]: &#39;also hidden&#39;
};

console.log(Object.keys(obj));                    // [&#39;publicProp&#39;]
console.log(JSON.stringify(obj));                 // {&quot;publicProp&quot;:&quot;visible&quot;}
console.log(Object.getOwnPropertySymbols(obj));   // [Symbol(secret1), Symbol(secret2)]
console.log(Reflect.ownKeys(obj));                // [&#39;publicProp&#39;, Symbol(secret1), Symbol(secret2)]&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;So symbols provide privacy from accidental access, but not from intentional inspection. It&#39;s like having a door that&#39;s closed but not locked - good enough to prevent accidents, useless against anyone who actually wants to get in.&lt;/p&gt;

&lt;h2&gt;Semicolons matter (sometimes)&lt;/h2&gt;

&lt;p&gt;While implementing symbol properties, I discovered this delightful parsing ambiguity:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;export class SomeClass {
  private stringName: string = &#39;StringNameOfClass&#39;
  [Symbol.toStringTag] = this.stringName  // Prettier goes mental
}&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Without a semicolon after the first line, Prettier interprets this as:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;private stringName: string = (&lt;strong&gt;&#39;StringNameOfClass&#39;[Symbol.toStringTag]&lt;/strong&gt; = this.stringName)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Because you can totally set properties on string literals in JavaScript (even though it&#39;s completely pointless), the parser thinks you&#39;re doing property access and assignment chaining.&lt;/p&gt;

&lt;p&gt;The semicolon makes it unambiguous, and impressively, Prettier is smart enough to recognize that this particular semicolon is semantically significant and doesn&#39;t remove it like it normally would.&lt;/p&gt;

&lt;h2&gt;Testing arrays vs. testing values&lt;/h2&gt;

&lt;p&gt;Completely unrelated to symbols, but I learned that Vitest&#39;s &lt;a href=&quot;https://vitest.dev/api/expect.html#tobe&quot; target=&quot;_blank&quot;&gt;&lt;samp&gt;toBe()&lt;/samp&gt;&lt;/a&gt; and &lt;a href=&quot;https://vitest.dev/api/expect.html#toequal&quot; target=&quot;_blank&quot;&gt;&lt;samp&gt;toEqual()&lt;/samp&gt;&lt;/a&gt; are different beasts:&lt;/p&gt;

&lt;pre class=&quot;source-code&quot;&gt;&lt;code&gt;expect(Object.keys(obj)).toBe([&#39;publicProp&#39;]);     // Fails - different array objects
expect(Object.keys(obj)).toEqual([&#39;publicProp&#39;]);  // Passes - same contents&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;samp&gt;toBe()&lt;/samp&gt; uses reference equality (like &lt;samp&gt;Object.is()&lt;/samp&gt;), so even arrays with identical contents are different objects. &lt;samp&gt;toEqual()&lt;/samp&gt; does deep equality comparison. This seems obvious in hindsight, but when you&#39;re in the middle of testing symbol enumeration behavior, it&#39;s easy to forget that arrays are objects too.&lt;/p&gt;

&lt;h2&gt;The real lesson&lt;/h2&gt;

&lt;p&gt;I set out to learn about symbols and ended up with a tour of JavaScript&#39;s most questionable design decisions:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Type coercion that doesn&#39;t work the way anyone would expect&lt;/li&gt;
  &lt;li&gt;Operators that behave differently based on hints that don&#39;t correspond to actual usage&lt;/li&gt;
  &lt;li&gt;Tooling that warns against legitimate language features&lt;/li&gt;
  &lt;li&gt;Parsing ambiguities that require strategic semicolon placement&lt;/li&gt;
  &lt;li&gt;Privacy models that aren&#39;t actually private&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is exactly why &quot;learn by doing&quot; beats &quot;read the documentation&quot; every time. The docs would never tell you about the ESLint conflicts, the semicolon parsing gotcha, or the &lt;samp&gt;+&lt;/samp&gt; operator&#39;s bizarre hint behavior. You only discover this stuff when you&#39;re actually writing code and things don&#39;t work the way they should.&lt;/p&gt;

&lt;p&gt;The symbols themselves are fine - they do what they&#39;re supposed to do. It&#39;s everything else around them that&#39;s&amp;hellip; erm&amp;hellip; &quot;laden with interesting design decision &quot;opportunities&quot;.[Cough].&lt;/p&gt;

&lt;hr&gt;

&lt;p&gt;The full code for this investigation is available in my &lt;a href=&quot;https://github.com/adamcameron/learning-typescript/tree/0.5/src/lt-11&quot; target=&quot;_blank&quot;&gt;learning-typescript repository&lt;/a&gt; if you want to see the gory details. Thanks to &lt;a href=&quot;https://claude.com/product/overview&quot; target=&quot;_blank&quot;&gt;Claudia&lt;/a&gt; for helping debug the type coercion weirdness and for assistance with this write-up. Also props to GitHub Copilot for pointing out that I had three functions doing the same thing - sometimes the robots are right.&lt;/p&gt;

&lt;p&gt;Righto.&lt;/p&gt;

&lt;p&gt;-- &lt;br&gt;Adam&lt;/p&gt;
</content><link rel='edit' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/1148283142857690014'/><link rel='self' type='application/atom+xml' href='https://www.blogger.com/feeds/8141574561530432909/posts/default/1148283142857690014'/><link rel='alternate' type='text/html' href='https://blog.adamcameron.me/2025/09/javascript-symbols-when-learning-one.html' title='JavaScript Symbols: when learning one thing teaches you fifteen others'/><author><name>Adam Cameron</name><uri>http://www.blogger.com/profile/04830762402027484810</uri><email>noreply@blogger.com</email><gd:image rel='http://schemas.google.com/g/2005#thumbnail' width='16' height='16' src='https://img1.blogblog.com/img/b16-rounded.gif'/></author></entry></feed>