Wednesday, 21 October 2026

Career progression for a dev who doesn’t write code

G'day:

We're an AI-only shop. Not "AI-assisted": the devs literally don't write code any more, the AI writes all of it. I hired a new dev a while back, and recently he asked me what career progression looks like for him here.

Good question. I didn't have a good answer. I am used to leading a team of devs - ones that actually write the code! - helped by QAs, annoyed by product donuts etc. Well at least I am still annoyed by the product donut (she will see this…). Some things don't change. I know how to help other devs progress through being a dev. But this new AI-driven approach? I'm a n00b at it myself.

My gut feeling was this: one can type as much into an AI prompt as one likes, but unless one is already a decent dev, one's not learning much from it. Getting usable results out of an AI is a necessary evil, not career progression. But that's a gut feeling, not an answer, so I set about thinking it through. Or at least doing a back-and-forth with another AI to try to tease out a position / a plan / a clue / etc.

I started with Gemini. Its first answer was a wall of stuff about being a "strategic gatekeeper", building "AI factories", and combining graph databases with vector stores. I told it to try again without sounding like part of the problem. The second attempt was less buzzword-heavy, but it had the same flaw as the first: all its advice assumed the dev was already an experienced engineer. Spot race conditions. Notice a nested loop that won't scale. Read stack traces. All fine, but that's the stuff one learns by doing the work, and doing the work is exactly what's been taken away.

Once I pointed that out, it agreed with me. And then it agreed with everything else I said too, telling me I'd given it the most accurate, clear-headed assessment it had heard in a long time. That's all lovely 'n' all, but it's no use when the point of the exercise is to have my thinking tested. So I stopped.

I picked it up again with Claude, using a skill that does nothing but question me about a plan, one question at a time, until there's nothing left to question. This article is what came out of that. Claude also drafted it, and I've then gone through and made it mine, as usual.

Fair warning: I'm not sure I've nailed this. It's where my thinking is at, not a career guide.

Coding vs programming

One thing I said to Gemini was that coding is to programming what typing is to writing a novel. Coding is the mechanical bit. The skill is working out what to type.

Which sounds good, but it's not quite right. The thinking doesn't all happen up front and then the typing happens afterwards. One thinks while one types. Writing the code is how one gets to the questions.

Take something routine: a connector to a third-party service with a well-documented API. Writing that by hand, one runs into the questions as one goes. What do we do on a 2xx? A 4xx? A 5xx? What does a 404 actually mean to our app: is it an error, or is it just "not found"? How am I going to test this? Oh, I'd better split the HTTP stuff out into an adapter so I can mock it. None of those are typing questions, but it's the typing that puts them in front of one.

In an AI-only shop, it's really easy to just go "we need a connector to [service], here are the docs" and let Claude get on with it. And it'll do a decent job. It'll handle the status codes in some sensible way, and it'll probably even split out an adapter (ed: Claude wrote that last bit. To be clear, no it won't split out the adapter unless prompted, or there's other code in the codebase to plagiarise/"learn" from). But it's Claude making those decisions, not the dev. The dev never has to stop and think about any of them.

So the problem isn't that the dev isn't typing. It's that the questions the typing used to force on them don't get asked any more. Or rather, they get asked and answered by Claude, and the dev just sees the result.

Getting the decisions back

If the issue is that Claude is answering the questions, then the fix is to get the dev answering them again. Without them writing the code, because that's not an option here.

I had dismissed TDD as irrelevant for AI-driven dev pretty early on. TDD is a way of managing human cognitive load, and Claude doesn't need that, so I got it to stop doing it. But the bit of TDD that matters here isn't the red/green/refactor loop; it's working out the test cases. The it("does this") part, not the implementation in the callback. And one doesn't need to write any code for that. Plain text will do:

  • on a 200 we return the object
  • on a 401 we throw an exception, because the app is misconfigured and we want an alert in Sentry
  • on a 500 we log something (what?), and re-queue the request for the next pass (or whatever)

Each of those is a decision about how the feature behaves. "What does a 404 mean to us?" gets answered by the dev, before anything is built. And the "(what?)" in that last one is the useful bit: that's the dev having to go and find out.

Narrative-only TDD, I guess. I'm not sure if I'm laughing or cringing at that.

Two things make this work.

First, Claude has to build what the dev decided, even if it would have done it differently. When I learned TypeScript I found out I was wrong because my code broke. If the dev decides a 404 should trigger a retry and Claude quietly does something more sensible instead, the dev never finds out it was a bad call. They need to own the result when it falls over in testing or review.

Second, we have a 100% coverage policy on our edge functions (which is where all our business logic is; the client code just makes the UI look all fancy and stuff, but it's UI-only code. We have e2e tests for that. Soon :-S). When coverage comes back under 100%, each uncovered bit of code is a case the dev didn't think of. The job then is for them to work out what that case is and add it to the list, not to get Claude to write a test that covers the line.

Claude can help with the questioning side too. I have a skill that does nothing but ask questions about a plan, one at a time (it's the one I used for this article). Pointed at the dev, and leaning towards "go and find out" rather than offering answers, it turns supervision into something they learn from.

But I'm not going to make any of this mandatory. It's a tool, not a rule. Rules like that get followed on paper and ignored in practice, and they get in the way of people using their common sense. And sometimes now is not the time: there's stuff that needs to go out the door, or it's Friday afternoon and there are post-work beers to be thinking about. In the end it's the dev who wants to progress. I can help, but they need to drive it themselves.

AI-only dev is its own trade

I'd also pointed out to Gemini that a dev's next job quite possibly won't be AI-only. I still think that's the exception rather than the rule. So does any of this keep them able to write code?

Partly. The decisions carry over: what a 404 means, what the test cases are, how to make something testable. Actually writing and debugging the code by hand doesn't. And as that's not happening at work, it'd have to happen in their own time.

But having talked this through with some colleagues elsewhere (OK: it was at the pub), I'm not sure it's the right question. AI-only dev and writing-the-code dev are different enough that one needs to decide which one is doing, or at least be realistic about it. If one moves from carpentry to hairdressing for a while, it's daft to wonder how the hairdressing is going to keep one's eye in for the next carpentry job. The two sorts of dev are a lot closer than that, obviously, but it's still an active decision to be doing one and not the other.

Which means there needs to be some answer to "how does one get better as an AI-only dev?". And I'd started out saying that getting better at cajoling the AI isn't progression. If AI-only dev is its own trade, then maybe it is.

Partly, again. There's the tooling side: "agentic" approaches (I don't even know what that means, and don't care enough to find out), skills, getting better at the language we use to get Claude to do things properly. That's all worth doing, but it changes every few months, so it's the least lasting part.

The lasting part is judgement. I chucked our MFA epic at my dev and went "here, do this lot", and he delivered. That in itself is progression. Working out when to get on with it alone. Working out when to ask questions. Working out when to go "WTF, Cameron??" at me. He got about 80% of it nailed, and there were just some testing gaps around the edges that were a bit "schoolboy error" (and shit, that is not a slight: we all still do those).

Part of that is knowing when one of the tickets I'd slung in the epic was easy to say but not so easy to do, and needed breaking down. We're pseudo-agile, so something like "add the feature toggle" is a deliverable, and can be its own ticket even if it's an hour's work and nothing is even checking the toggle yet. But it's there to use when some subsequent piece of work needs to be gated behind it. That's planning, not typing. And it's the sort of thing one works out while doing the work, which is the same point as before: the thinking happens during the doing.

None of that is specific to AI-only dev. It's the same in both trades.

From intermediate to senior

Something that didn't come out clearly in any of this until late on: the step from intermediate to senior is as much about communication and responsibility as it is about technical ability. That was true before AI, and it's still true now.

My tutor said something to me back in 1992 that I didn't get at the time: "our job is communication, not code". It took me about twenty years to understand it.

A lot of that communication is with the people asking for things. Someone wants the system to generate a CSV file so they can open it in Excel and fix the formatting. OK, but how about we generate a correctly-formatted XLSX file in the first place? And what's it for? "To get the data into another system." OK, so how about we just send the data to the other system? Working out what the ticket should say, and whether it's actually an epic with ten tickets in it. That's a skill, and it's one AI-only dev doesn't change much at all.

At the moment most of my dev's tickets come through me, so he doesn't get much practice at that. That needs to change. The plan is to hand him a small, low-risk request from someone internal now and then, sit in on the first one, and then let him do the next one on his own and tell me what the ticket should say.

Full disclosure: I put that in the future tense for the purposes of the narrative flow in this article, but we're already doing this. I've left the product donut and him to sort out an integration with a fulfilment supplier, because she knows as much about it as I do, and it's all fairly unsurprising. I only needed to help with the approach to the test environment, not the implementation.

The other part is owning the business rules. Being the person people ask "how does this work?" about one area of the system, and the person who pushes back when a ticket would break one of its rules. He's doing that already too: he pushed back on me about some of the MFA work, because his head was more in it than mine was. That's the start of the move away from the code and towards all the other things a more senior dev needs to do.

So what does moving up look like? I hand him a vague request, he works it out with whoever asked for it, splits it into the right tickets, and the test cases he writes up front catch the things I'd have caught. That's what I've told him, and the boss.

Readable code matters more, not less

I get Claude to stick to coding standards that are all about human cognition: short methods, small pull requests, shallow nesting, no duplication. The stuff Robert C Martin bangs on about as "Clean Code" when he's not being a misogynist. Code is for humans, not computers. But in an AI-only shop I did wonder whether I was just cargo-culting: an old dog not wanting to learn new tricks.

Having discussed it back and forth (in my head, with various AIs as slightly-informed rubber ducks), I've concluded it's still sound.

For a start, my dev doesn't write code any more. Reading Claude's code is the only exposure to code he gets. If it's an unreadable mess, he learns nothing from it.

And we still need to troubleshoot stuff. Small methods make it easier to find the right place in the code when something goes wrong. Well-factored code, rather than the same logic duplicated all over the bloody show, means less to keep in one's head when making changes later. That's true for everyone who has to touch it.

(Writing this the following day, during editing)

And it turns out this is not an occasional or theoretical case at all. I have just spent the last 30min looking through code to find an off-by-one error we'd introduced last week. I guess I could have got the AI to find it, but I wanted to have the case solid in my own head, as this is now the second time fixing this issue. So I needed to be able to read the code. I was faced with well-factored code, short methods, (mostly) clear names, and nailed the cause of the error - both the wrong code and why it's wrong - quickly. Because I could read the code. If this was just some "vibe-coded AI slop", it'd all be in one big function in one big file, with AI slop naming, and be largely impenetrable.

We also wondered (at the pub again) whether it makes any difference to the AI. That's hard to know, as it's all a bit impenetrable. Claude reckons the difference is accuracy rather than speed: if the same logic is in five places, a change needs to be made in five places, and it misses copies the same way a person does. That sounds plausible, but it couldn't point me at anything solid to back it up, so I'll leave it as a maybe.

However…

Here's the bit I haven't got an answer for.

I started this project AI-only so I could get through the work faster in a language I sort of know, but am not professional at yet. Will I come out of it a better TypeScript dev? No. I'll be better at shipping TypeScript via Claude. And that works because 25 years of doing this lets me judge what Claude gives me, even in a language I'm new to.

Last week we needed webhooks, as prep for some integration work with other systems. I didn't know how webhooks are built. All I knew was: let them register their URLs, send them notifications, and we'd better have an API so they can act on the notification. I told Claude pretty much that, and let it implement the industry-standard approach. I was thinking "this'll take weeks". It took about half a day of elapsed time, and I knocked off another dozen unrelated tickets while waiting for it to finish each bit. It all worked 100% first time.

But I knew to ask for those three things because I've seen systems like that before. Someone starting out wouldn't have known to ask.

AI-only works best for the people who need it least. I reckon one needs to be at least five years in before one can drive it properly. Possibly more. And I mean five years, not "the same one year, five times over" as we see with… a lot… of devs. Which leaves the question: how do people with no experience get started? They can't drive AI dev. And if nobody's writing code any more, where do they get the experience they need to be able to?

I don't know. That's not one for me to solve with one dev on one team. But it worries me.

Bit of a bleak note to end on. Ah well.

Righto.

--
Adam


 

 

Because it's easy to mishear "product owner" as "product donut". It makes me laugh, and it's kinda stuck with us @ work.

Tuesday, 1 September 2026

"Don't mock what you don't own" is a rubbish way of articulating good app design and testing advice

The practice is sound. The slogan is catchy, but the reasoning usually articulated is… "less good than it could be".

Look it up and you get roughly this:

Never write mocks for third-party libraries, frameworks or services you don't control. Wrap the dependency in your own adapter, and mock that adapter in your unit tests instead.

The justifications given are almost always (some subset of ~):

  • A mock of someone else's library encodes your assumptions about how that library behaves, not how it actually behaves.
  • When the vendor ships a breaking change, your mocks keep passing while production falls over.
  • Mocks of third-party APIs need piles of setup and are miserable to maintain.
  • API changes force you to rewrite mock configuration all over the test suite.

Then a nod to interfaces, ports and adapters, and everyone goes home feeling like they've learned something.

The second reason is the one people repeat most, and it's the one that doesn't survive being thought about.

If I mock my own adapter, my unit test still knows nothing about whether the vendor changed their API. The test passes. Production falls over. That is precisely the failure the advice claims to prevent, and moving the mock one layer inwards does not prevent it. A unit test cannot detect a change in a system it never speaks to. True whether the mock sits at the vendor's client or at my wrapper around it.

So the silent-production-failure argument doesn't get you to adapters. It gets you to integration tests, which are the only thing that catches a vendor's breaking change. And once you have those, what you happened to mock in your unit tests is beside the point.

If catching breakage were really the goal, the honest advice would be one of two other things. Either "don't mock third-party services at all, test against the real thing", or "mock for unit tests, and write integration tests as well". Neither of those is "don't mock what you don't own".

Take the bogus reason away though, and the practice still stands; just on different grounds.

You use a small slice of most third-party libraries. An SDK exposes fifty methods and you need two. An adapter hides the other forty-eight, and your unit tests only ever have to stand in for the two things your code actually does.

Vendor APIs are shaped for the vendor's convenience. Mocking stripe.customers().create().charges().create() means reproducing the library's internal mechanics in your test setup, so the test ends up asserting things about someone else's design decisions. payments.charge(amount) is one line, and it says what your code wanted to happen.

Vendor types spread. If their request objects, response objects and exception classes are visible in your business logic, they'll be visible in the tests for that logic, and then in every test file that touches it. When the API changes you go and fix all of them. Keep those types inside the adapter and you fix the adapter plus its integration test. The rest of the suite doesn't move.

Your domain deserves its own vocabulary. The vendor says "Account", your business says "Member". The adapter translates once, in one place, and your code and your tests both talk about Members.

Your code changes on your schedule. The library changes on theirs. The adapter is where those two schedules meet, so an upgrade lands in one file rather than forty.

Notice that none of that has anything to do with catching a vendor's breaking change. It is all about how much vendor-shaped detail is allowed into your codebase, and where the bits you can't avoid end up living.

Which suggests saying something more like this:

Wrap third-party dependencies in adapters that speak your domain's language. Integration test the adapter against the real service. Mock the adapter everywhere else.

Longer. Less catchy. Says what it means, and doesn't pretend a unit test can see something it demonstrably can't.

The slogan survives because it's short, and because repeating it sounds like expertise. That's how most of this propagates. Someone reads a heuristic, likes the shape of it, passes it on with the reasoning slightly garbled, and a few hops later it's a rule with a broken justification welded to it. Then a colleague asks why, you hand them the broken justification, and they put a hole in it in about ten seconds. Fair enough too.

Keep the practice. Bin the slogan, or at the very least bin the explanation that usually comes with it.

Thanks to my mate and namesake Adam Tuttle for pulling me up on this when I used it when describing a juncture I had between unit and integration testing I had. This make me think about why I say "don't mock what you don't own", and made me wonder if I was cargo-culting bad advice. No. The advice is sound. The "pop" way of articulating it a bit shite.

Righto.

--
Adam

PS: drafted by AI, polished by me. Also based on a "conversation" I had with a different AI. AI was used as a time-saving device, not a creative one.

Thursday, 22 January 2026

Setting up dev/prod environments with Lovable and Supabase

G'day:

This is one of those "we really should have done this from the start" situations that catches up with you eventually.

We've been building an e-commerce admin application using Lovable (an AI coding platform that generates React/TypeScript apps with Supabase 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.

The problem: we'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 "let's try this and see what happens" experiment - all happening in the same environment that would eventually serve real users.

This is fine when you're prototyping. It's less fine when the Product Owner has been merging features over the Christmas break and you've suddenly got 9 pull requests to audit before you can safely call anything "production ready".

Time to sort out proper dev/test/prod environments. How hard could it be?

The single environment problem

Before we get into the solution, let's be clear about what was wrong with the setup.

We had one Supabase project. Everything happened there:

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

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't break things, no safety net.

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

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

Lovable Cloud: the auto-provisioning disaster

We'd actually tried to set up separate environments once before, and it went spectacularly wrong.

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.

Except before we could do any actual work, Lovable forced us to enable "Lovable Cloud". This wasn't presented as optional - it was a "you must do this to proceed" situation. Fair enough, we thought, probably just some hosting infrastructure it needs.

Wrong.

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 cfjdkppbukvajhaqmoon.supabase.co when we'd explicitly configured it to use twvzqadjueqejcsrtaed.supabase.co.

It turns out Lovable has two completely different modes:

  • Lovable Cloud - auto-provisions its own Supabase, manages everything, cannot be overridden
  • External Supabase - you bring your own Supabase project and manage it yourself

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

The fix: delete the project entirely and start again with explicit "DO NOT enable Cloud" instructions from the beginning. Not ideal, but it worked.

Understanding the pieces

Once we'd learned our lesson about Lovable Cloud, we needed to properly understand how environment separation actually works with this stack.

The key insight came from an article by someone who'd already solved this problem: Lovable Branching. Their approach was straightforward:

  • DEV: Lovable project → dev GitHub branch → DEV Supabase instance → Lovable hosting
  • PROD: Same repo → main GitHub branch → PROD Supabase instance → Netlify hosting

The critical bit: completely separate Supabase instances. Not Supabase's branching feature (which exists but is more for preview environments), actual separate projects. One for development, one for production, zero overlap.

This makes sense when you think about it. Database migrations aren't like code - you can'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.

Environment variables in three different contexts

Where things get interesting is environment variables. Turns out there are three completely different systems at play:

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

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

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

The important realisation: these three systems don't talk to each other. Your frontend env vars in .env 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.

The VITE_SUPABASE_PROJECT_ID battle

This is where things got properly frustrating.

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

So we asked Lovable to remove it from the .env file.

Every. Single. Commit. It put it back.

We tried being explicit: "We don't use VITE_SUPABASE_PROJECT_ID, please remove it". Lovable responded "Yes, done" and left it there. We manually deleted it and pushed the change ourselves. The next commit from Lovable put it back. We explained why it was redundant. Lovable agreed with the reasoning, confirmed it would remove the variable, and then didn't.

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

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't do anything, the Supabase client doesn't use it, but it's there. Lovable won.

This became a useful lesson about AI code generation tools: they'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't process. Even when it claims to understand.

The review workflow

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

The workflow went like this:

  1. Lovable generates code based on a prompt from the Product Owner
  2. I creates a pull request from the feature branch to dev
  3. GitHub Copilot does an automated review, catching obvious issues
  4. I review the code manually, looking for security concerns, deployment gotchas, architectural problems
  5. Claudia reviews it as well, often catching things I missed
  6. We compile a comprehensive list of issues and create a fix prompt for Lovable
  7. Lovable makes another pass, addressing the feedback
  8. Repeat until the code is actually mergeable

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

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

One recurring annoyance: GitHub Copilot's automated review insists on suggesting American spellings. "Initialize" instead of "initialise", "color" instead of "colour". Every. Single. Review. I'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.

The key insight here is that AI code generation isn't "press button, receive working code". It'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.

The actual working solution

After all the false starts and battles with Lovable's helpful tendencies, here's what actually works.

The architecture

DEV environment:

  • Supabase project: the original instance we'd been using all along
  • GitHub branches: DEV for integration, and each ticket's work is done in a short-lived feature branch of DEV, eg JRA-1234_remove_project_id_AGAIN
  • Hosting: Lovable's built-in preview hosting (good enough for dev work)
  • Database: DEV Supabase instance with all our test data and experimental migrations

PROD environment:

  • Supabase project: fresh instance created specifically for production
  • GitHub branch: main only
  • Hosting: Netlify (automatic deployment on push to main)
  • Database: PROD Supabase instance with clean migration history

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.

The deployment process

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

Backend deployment is deliberately manual. No automation, no automatic deploys on merge, no clever CI/CD pipelines. When we're ready to deploy database changes to production:

npx supabase login
npx supabase link --project-ref <PROD_PROJECT_ID>
npx supabase db push
npx supabase functions deploy

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

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

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

Oh, one thing I didn'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 db push and functions deploy to get it up to date with dev.

Keeping branches in sync

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

Edge functions and secrets

Edge functions turned out to be simpler than expected, once we understood how Supabase handles them.

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

Environment variables for edge functions work through Supabase's "Edge Function Secrets" system. Some are auto-managed by Supabase itself:

  • SUPABASE_URL
  • SUPABASE_ANON_KEY
  • SUPABASE_SERVICE_ROLE_KEY
  • SUPABASE_DB_URL

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.

Any other environment variables need to be set manually per environment. For our project, this included:

  • EMAIL_API_KEY - for sending emails
  • BANK_API_ACCESS_TOKEN - for our bank's API integration
  • BANK_API_WEBHOOK_SECRET - webhook signature verification
  • BANK_API_HOST - the API hostname (different for sandbox vs production)

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

You set these via the Supabase dashboard (Authentication → Secrets) or via the CLI:

npx supabase secrets set BANK_API_HOST=api.ourbank.com

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

This is then used in our edge function along these lines:

function getOurBankApiUrl(): string {
  const apiHost = Deno.env.get('BANK_API_HOST');
  if (!apiHost) {
    throw new Error(
      'Missing bank configuration. ' +
      'Set BANK_API_HOST environment variable.'
    );
  }
  return `https://${apiHost}/some_slug_here`;
}

JWT verification settings

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

[functions.ourbank-webhook]
verify_jwt = false

[functions.validate-bank-details]
verify_jwt = false

This is the only thing that should be in your config.toml. 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're not doing. The JWT settings are needed because npx supabase functions deploy falls back to these values.

Gotchas and non-obvious behaviours

Here's everything that wasn't obvious from the documentation, discovered through trial and error.

Supabase CLI tool location changed

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

This directory must be in .gitignore because it'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:

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

The "PRODUCTION" badge means nothing

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

npx is not npm install -g

This is just me not being a Node dev.

Running npm install -g supabase returns a warning: "Installing Supabase CLI as a global module is not supported." Instead, use npx supabase <command> for everything. This downloads the CLI on-demand, caches it in npm's global cache, and executes it. It's not "installed" in the traditional sense, but it works identically from a user perspective.

First-time use requires npx supabase login which opens a browser for OAuth authentication. This stores an access token locally. Without this, CLI commands fail with "Access token not provided".

Netlify build doesn't touch the database

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

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.

React Router and direct navigation

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

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's literally one HTML file (index.html). React Router handles navigation by swapping components in JavaScript, updating the browser's address bar without making server requests.

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

I'll be honest - this felt like broken behaviour to me. Surely "being able to navigate to any page directly" 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 index.html for all routes. I felt a bit thick when Claudia explained this to me using small words.

The fix is simple: create public/_redirects with one line:

/* /index.html 200

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

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

This should probably be included by default in any SPA scaffolding tool, but it's not. Add it to your "first deploy checklist" and move on.

Environment variable override precedence

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

Migration file format matters

Supabase requires migration files to use timestamp format: YYYYMMDDHHMMSS_description.sql. Lovable doesn'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:

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

Even with this, Lovable occasionally forgets, or will use a GUID in place of the description. Code review catches it.

Supabase key format evolution

Supabase has two key formats in the wild:

  • Legacy anon public key (JWT format): rlWuoTpv...
  • New publishable key format: sb_publishable_...

Both work, but the dashboard now recommends using publishable keys. We'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're not in the middle of setting up production.

What we ended up with

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

The key decisions that made it work:

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

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's not fancy, there's no sophisticated CI/CD pipeline, but it's appropriate for a small team building an MVP.

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 technically proficient human. The review workflow isn't overhead; it's where the actual quality control happens.

Don't get sucked into the hype: "vibe coding" is simply not a thing when it comes to production applications. It's only good for building functional demos.

And sometimes, you just have to accept that VITE_SUPABASE_PROJECT_ID 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.

Righto.

--
Adam


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 dev and uses the DEV Supabase credentials. It's exposed to the world on the admin-test subdomain (live is just admin). 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.

Friday, 12 December 2025

Lovable and its seemingly self-defeating pricing and business model

G'day:

Sigh. I watched a video about Lovable 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's this one: Master Lovable AI in 20 Minutes (NEW 2.0 UPDATE).

One of the tactics the author detailed was this thing in Lovable that - despite it being an AI-driven app generator thingey (you know "vibe coding" etc), the standard approach to things seems to be to use someone else's AI tooling to do most of the planning / AI / back-and-forth work, and then get - eg - ChatGPT to summarise the plan as a "prompt" 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.

So I asked the question in the video comments:

@darrelwilson 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 "cognitive" par with ChatGPT's LLM... so what'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's memory-context tight & not cluttered with intermediary discussions with ChatGPT whilst drafting the "ideal" prompt? Keeping zeitgeisty, I asked Claude Desktop's Opus 4.5 why one might do this, and its reaction was bemusement, as it seems like an unnecessary step..?

Someone replied:

@adamcameron1 I mean you should really read the prompt GPT provides. If you put a generic prompt into Lovable directly youll have less control & say into what it builds. Its going to extroplate if you aren't specific.

That didn't quite land with me, so I followed-up:

Hi @shenshaw5345, I appreciate the response, cheers!

Reasoning about this, I'd expect one would not charge in and go "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"; one would do precisely what was done in this example via ChatGPT first, and "discuss" 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 "OK build that". Not all chat prompts need to result in code being written, right?

NB: I'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 "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". I'm presuming Lovable works the same, given it's the same underlying AI models...

THAT SAID, I just checked Lovable's pricing, and it'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'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'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.

This, however, explains why one ought not plan using Lovable. They've hamstrung their offering with their pricing. Duly noted.

Thanks again!

And someone else came back to me:

@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

This respondent didn't quite get what I was meaning, so I followed up. And this leads to the reason I am reposting this here:

Hi @youcanfindrace. It might be that. But given I'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'm not so sure I'm misunderstanding how it all comes together. But it's possible, sure.

What I rather more think is that I wasn't as clear as I could have been previously, and my point didn't land with you. Let me change a few things around, and try to be clearer.

Let's pretend the guidance given wasn't "use ChatGPT to build a prompt", but instead "use Claude.ai to build a prompt". You can hopefully see how "using ChatGPT" and "using Claude" 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're still drills. So we use Claude to build a lovely fully-realised prompt, which we then paste into Lovable's UI... which then uses the same AI as Claude uses to analyse the prompt and perform that action Lovable uses Anthropic's LLMs under the hood (so... same as Claude... same thing... different wrapper, by a different vendor). But even if it's not a wrapper around the same tool, it's still an analogous tool (Lovable=>DeWalt; Claude=>Ryobi) in and of itself. "use a chat with ChatGPT to build a prompt", "use a chat with Claude to build a prompt" and "use a chat with Lovable to build a prompt" are analogous exercises. EXCEPT... that Lovable simply don'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... actual AI stuff.

"Building a prompt" is only a tool to transfer the summary of an AI'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 "get the other AI up to speed", is to ask the initial AI "can you please summarise this so I can give it to Gemini and see what they think". One only builds a prompt when doing this context-interchange. However these summaries always lose context and nuance from the underlying chat (think Cliff's Notes vs the actual novel the notes are summarising). In application building, that context/nuance is really important.

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

However I have done further research into this, and have identified the reason for the way it is (well: others have, I'm just parroting it here). It's simply a bad business model on Lovable's part, basically. They'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 "credits" 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're basically given away. A chat in Claude is allocated 190k tokens. 190000. And - as an example - I'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'm on a $50/month plan I think, and I've never run into a wall with token usage. And these are the same tokens that Lovable are using under the hood. There'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.

That's why the ChatGPT-as-intermediary workflow exists.

And the comment was promptly deleted. I dunno if it was by YT'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 "shrug" response, in the hope that the person I was trying to reply to would see that I did value their feedback to me:

@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't post it here. [eyeroll].

At time of writing, that reply is still there.

Anyhoo, I'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'm reproducing it here where I own it.

And that's that.

Righto.

--
Adam

Friday, 10 October 2025

TypeScript: any, unknown, never

G'day:

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

The Jira ticket I set up for this was straightforward enough:

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

Simple, right? any turns off TypeScript's safety checks, unknown keeps them on. I built some examples, wrote some tests, and thought I was done.

Then Claudia pointed out I'd completely missed the point of type narrowing. I was using type assertions (value as string) instead of type guards (actually checking what the value is at runtime). Assertions just tell TypeScript to trust you. Guards actually verify you're right.

Turns out there's a difference between "making the compiler happy" and "writing safe code".

any - when you genuinely don't know or don't care

I started with a generic key-value object that could hold anything:

export type WritableValueObject = Record<string, any>
export type ValueObject = Readonly<WritableValueObject>

type keyValue = [string, any]

export function toValueObject(...kv: keyValue[]): ValueObject {
  const vo: WritableValueObject = kv.reduce(
    (valueObject: WritableValueObject, kv: keyValue): ValueObject => {
      valueObject[kv[0]] = kv[1]
      return valueObject
    },
    {} as WritableValueObject
  )
  return vo
}

(from any.ts)

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

The Readonly<...> 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 Readonly<WritableValueObject> instead of just ValueObject because TypeScript helpfully expands type aliases in error messages. Sometimes this is useful (showing you what the type actually is), sometimes it's just verbose.

unknown - the safer alternative that's actually more annoying

The unknown version looks almost identical:

export type WritableValueObject = Record<string, unknown>
export type ValueObject = Readonly<WritableValueObject>

type keyValue = [string, unknown]

export function toValueObject(...kv: keyValue[]): ValueObject {
  const vo: WritableValueObject = kv.reduce(
    (valueObject: WritableValueObject, kv: keyValue): ValueObject => {
      valueObject[kv[0]] = kv[1]
      return valueObject
    },
    {} as WritableValueObject
  )
  return vo
}

(from unknown.ts)

The difference shows up when you try to use the values. With any, you can do whatever you want:

const value = vo.someKey;
const reversed = reverse(value); // Works fine with any

With unknown, TypeScript blocks you:

const value = vo.someKey;
const reversed = reverse(value); // Error: 'value' is of type 'unknown'

My first solution was type assertions:

const reversed = reverse(value as string); // TypeScript: "OK, if you say so"

This compiles. The tests pass. I thought I was done.

Then Claudia pointed out I wasn't actually checking anything - I was just telling TypeScript to trust me. Type assertions are a polite way of saying "shut up, compiler, I know what I'm doing". Which is fine when you genuinely do know, but defeats the point of using unknown in the first place.

Type guards - actually checking instead of just asserting

The proper way to handle unknown is with type guards - runtime checks that prove what type you're dealing with. TypeScript then narrows the type based on those checks.

The simplest is typeof:

const theWhatNow = returnsAsUnknown(input);

if (typeof theWhatNow === 'string') {
  const reversed = reverse(theWhatNow); // TypeScript knows it's a string now
}

(from unknown.test.ts)

Inside the if block, TypeScript knows theWhatNow is a string because the typeof check proved it. Outside that block, it's still unknown.

For objects, use instanceof:

const theWhatNow = returnsAsUnknown(input);

if (theWhatNow instanceof SomeClass) {
  expect(theWhatNow.someMethod('someValue')).toEqual('someValue');
}

And for custom checks, you can write type guard functions with the is predicate:

export class SomeClass {
  someMethod(someValue: unknown): unknown {
    return someValue
  }

  static isValid(value: unknown): value is SomeClass {
    return value instanceof SomeClass
  }
}

(from unknown.ts)

The value is SomeClass return type tells TypeScript that if this function returns true, the value is definitely a SomeClass:

if (SomeClass.isValid(theWhatNow)) {
  expect(theWhatNow.someMethod('someValue')).toEqual('someValue');
}

This is proper type safety - you're checking at runtime, not just asserting at compile time.

Error handling with unknown

The most practical use of unknown is in error handling. Before TypeScript 4.0, everyone wrote:

try {
  throwSomeError('This is an error')
} catch (e) {  // e is implicitly 'any'
  console.log(e.message)  // Hope it's an Error!
}

Now you can (and should) use unknown:

try {
  throwSomeError('This is an error')
} catch (e: unknown) {
  expect(e).toBeInstanceOf(SomeError)
}

(from unknown.test.ts)

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

So which one should you use?

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

const value = getValueForKey(vo, 'someKey');
if (typeof value === 'string') {
  doSomething(value);
}

versus just:

const value = getValueForKey(vo, 'someKey');
doSomething(value as string);

For a genuinely generic container where you're accepting "no idea what this is" as part of the design, any is the honest choice. You're not pretending to enforce safety on truly dynamic data.

But for error handling, function parameters that could be anything, or situations where you'll actually check the type before using it, unknown is the better option. It forces you to handle the uncertainty explicitly rather than hoping for the best.

never - the type that can't exist

While any and unknown are about values that could be anything, never is about values that can't exist at all. It's the bottom type - nothing can be assigned to it.

The most obvious use is functions that never return:

export function throwAnError(message: string): never {
  throw new Error(message)
}

(from never.ts)

Functions that throw or loop forever return never because they don't return at all. TypeScript uses this to detect unreachable code:

expect(() => {
  throwAnError('an error')
  // "Unreachable code detected."
  const x: string = ''
  void x
}).toThrow('an error')

(from never.test.ts)

The const x line gets flagged because TypeScript knows the previous line never returns control.

Things get more interesting with conditional never:

export function throwsAnErrorIfItIsBad(message: string): boolean | never {
  if (message.toLowerCase().indexOf('bad') !== -1) {
    throw new Error(message)
  }
  return false
}

The return type says "returns a boolean, or never returns at all". TypeScript doesn't flag unreachable code after calling this function because it might actually return normally.

Exhaustiveness checking

The clever use of never is exhaustiveness checking in type narrowing:

export function returnsStringsOrNumbers(
  value: string | number
): string | number {
  if (typeof value === 'string') {
    const valueToReturn = value + ''
    return valueToReturn
  }
  if (typeof value === 'number') {
    const valueToReturn = value * 1
    return valueToReturn
  }
  const valueToReturn = value // TypeScript hints: const valueToReturn: never
  return valueToReturn
}

(from never.ts)

After checking for string and number, TypeScript knows that value can't be anything else, so it infers the type as never. This is TypeScript's way of saying "we've handled all possible cases".

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

The actual lesson

I went into this thinking I understood these types well enough - any opts out, unknown is safer, never is for functions that don't return. All true, but missing the point.

The real distinction is between compile-time assertions and runtime checks. Type assertions (as string) tell TypeScript "trust me", but they don't verify anything. Type guards (typeof, instanceof, custom predicates) actually check at runtime.

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

And never is TypeScript's way of tracking control flow and exhaustiveness, which is useful when you actually pay attention to what it's telling you.

The code for all this is in the learning-typescript repository, 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's a difference between making the compiler happy and writing safe code.

Righto.

--
Adam

Monday, 6 October 2025

Setting up a Python learning environment: Docker, pytest, and ruff

G'day:

I'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've been a web developer for decades, but Python's never been part of my stack. Time to fix that gap.

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.

The repo's at github.com/adamcameron/learning-python (tag 3.0.2), and I'm tracking this as Jira tickets because that's how my brain works. LP-1 was the Docker setup, LP-2 was the testing and linting toolchain.

Getting Docker sorted

First job was getting a Python container running. I'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.

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

The docker-compose.yml is straightforward:

services:
    python:
        build:
            context: ..
            dockerfile: docker/python/Dockerfile

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

        stdin_open: true
        tty: true

volumes:
    venv:

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

The Dockerfile handles the initial setup:

FROM astral/uv:python3.11-bookworm

RUN echo "alias ll='ls -alF'" >> ~/.bashrc
RUN echo "alias cls='clear; printf \"\033[3J\"'" >> ~/.bashrc

RUN ["apt-get", "update"]
RUN ["apt-get", "install", "-y", "vim"]

WORKDIR  /usr/src/app

ENV UV_LINK_MODE=copy

RUN \
    --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync \
    --no-install-project

ENTRYPOINT ["bash"]

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

The ENTRYPOINT ["bash"] keeps the container running so I can exec into it and run commands. I'm used to having PHP-FPM containers that stay up with their own service loop, and this achieves the same thing.

One thing I'm doing here differently from usual, is that I am using mount to temporarily expose files to the Docker build process. In the past I would have copied pyproject.toml into the image file system. Why the change? Cos I did't realise I could do this until I saw it in this article I googled up: "Using uv in Docker › Intermediate layers"! I'm gonna use this stragey from now on, I think…

Project configuration and initial code

Python projects use pyproject.toml for configuration - it's the equivalent of package.json in Node.js or composer.json in PHP. Here's the initial setup:

[project]
name = "learning-python"
version = "0.1"
description = "And now I need to learn Python..."
readme = "README.md"
requires-python = ">=3.11"
dependencies = []

[project.scripts]
howdy = "learningpython.lp2.main:greet"

[build-system]
requires = ["uv_build>=0.8.15,<0.9.0"]
build-backend = "uv_build"

[tool.uv.build-backend]
namespace = true

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

The namespace = true bit tells uv to use namespace packages, which means I don't need __init__.py files everywhere. Modern Python packaging is less fussy than it used to be.

The actual code in src/learningpython/lp2/main.py is about as simple as it gets:

def greet():
    print("Hello from learning-python!")

if __name__ == "__main__":
    greet()

Nothing to it. The if __name__ == "__main__" bit means the function runs when you execute the file directly, but not when you import it as a module. Standard Python pattern.

With all this in place, I could build and run the container:

$ docker compose -f docker/docker-compose.yml up --detach
[+] Running 2/2
 ✔ Volume "learning-python_venv"  Created
 ✔ Container learning-python-python-1  Started

$ docker exec learning-python-python-1 uv run howdy
Hello from learning-python!

Right. Basic container works, simple function prints output. Time to sort out testing.

Installing pytest and development dependencies

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

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

I wanted to pin pytest to major version 8, so I checked PyPI (pypi.org/project/pytest/) to see what was current. As of writing it's 8.4.2. Python uses different version constraint syntax than Composer - instead of ^8.0 you write >=8.0,<9.0. More verbose but explicit.

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

After running uv add --dev pytest pytest-watcher, the pyproject.toml updated to include:

[dependency-groups]
dev = [
    "pytest>=8.4.2,<9",
    "pytest-watcher>=0.4.3,<0.5",
]

The uv.lock file pins the exact versions that were installed, giving reproducible builds. It's the Python equivalent of composer.lock or package-lock.json.

Writing the first test

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

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

from learningpython.lp2.main import greet

def test_greet(capsys):
    greet()
    captured = capsys.readouterr()
    assert captured.out == "Hello from learning-python!\n"

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

Running the test:

$ 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 =========================================

Green. The test found the pyproject.toml config automatically and discovered the test file without needing to tell it where to look.

For continuous testing, pytest-watcher monitors files and re-runs tests on changes:

$ docker exec learning-python-python-1 uv run ptw
[ptw] Watching directories: ['src', 'tests']
[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 =========================================

Any time I change a file in src or tests, it automatically re-runs the relevant tests. Much faster feedback loop than running tests manually each time.

Code formatting and linting with ruff

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

Installation was the same pattern: uv add --dev ruff. This added "ruff>=0.8.4,<0.9" to the dev dependencies.

ruff has two main commands:

  • ruff check - linting (finds unused variables, style issues, code problems)
  • ruff format - formatting (fixes indentation, spacing, line length)

Testing it out with some deliberately broken code:

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

It caught the unused variable. It also didn't complain about the 7-space indentation, because ruff check is about code issues, not formatting. That's what ruff format is for:

$ docker exec learning-python-python-1 uvx ruff format src/learningpython/lp2/main.py
1 file reformatted

This fixed the indentation to Python's standard 4 spaces. The check command can also auto-fix some issues with --fix, similar to eslint.

I configured IntelliJ to run ruff format 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't understand why AMD thinks its driver software needs hotkeys. Dorks.

A Python gotcha: hyphens in paths

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

[project.scripts]
howdy = "learningpython.lp-2.main:greet"

This did not go well:

$ 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 "/usr/src/app/.venv/bin/howdy", line 4
    from learningpython.lp-2.main import greet
                            ^
SyntaxError: invalid decimal literal

Python's import system doesn't support hyphens in module names. When it sees lp-2, it tries to parse it as "lp minus 2" and chokes. Module names need to be valid Python identifiers, which means letters, numbers, and underscores only.

Renaming to lp2 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 python path/to/some-script.py), but not in anything you're importing as a module.

This caught me out because hyphens are fine in most other ecosystems. Coming from PHP and JavaScript where some-module-name is perfectly normal, Python's stricter rules take some adjustment.

Wrapping up

So that'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.

The final project structure looks like this:

learning-python/
├── docker/
│   ├── docker-compose.yml
│   └── python/
│       └── Dockerfile
├── src/
│   └── learningpython/
│       └── lp2/
│           └── main.py
├── tests/
│   └── lp2/
│       └── test_main.py
├── pyproject.toml
└── uv.lock

Everything's on GitHub at github.com/adamcameron/learning-python (tag 3.0.2).

Now I can actually start learning Python instead of fighting with tooling. Which is the point.

Righto.

--
Adam

Saturday, 4 October 2025

Violating Blogger's community guidelines. Apparently.

G'day:

Earlier this evening I published TypeScript decorators: not actually decorators. And about 5min after it went live, it vanished. Weird. Looking in the back-end of Blogger, I see this warning:

This post was unpublished because it violates Blogger's community guidelines. To republish, please update the content to adhere to the guidelines.

What? Seriously?

Looking through my junk folder in my email client, I had an email thus:

Hello,

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

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

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.

For more information, please review the following resources:

Sincerely,

The Blogger Team

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

I did make a coupla edits and resubmitted it:

  • Updated text in the first para to read "what the heck". You can imagine what it said before the edit. Not the only instance of that word in this blog, as one can imagine.
  • I was using my son's name instead of "Jed Dough". I have used Z's name a lot in the past, so can't see it was that.
  • I used a very cliched common password as sample data in place of tough_to_guess.
  • I removed most of one para. The para starting "Worth learning?" went on to explain how some noted TypeScript frameworks used decorators heavily. Why did I remove this? Well: Claudia wrote it, and this came from her knowledge not my own. I didn't know those frameworks even existed, let alone used decorators. I admonished her for using original "research", 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't my own work, so I thought I'd get rid. I had included a note there that it was her and not me. There's nothing in the T&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 admission it was AI-written; rather than try to shadily pass AI work as my own. Which, if you read this blog, I don't do. I always say when she's helped me draft things. And I always read what she's done ands tweak where necessary anyhow. It's my work.

And that was it. But maybe 30min later I got another email from them:

Hello,

We have re-evaluated the post titled 'TypeScript decorators: not actually decorators' 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.

Sincerely,
The Blogger Team

Cool. No harm done, but I'd really like to know what triggered it. Of course they can't tell me as that would be leaking info that bad-actors could then use to circumvent their system. I get that. And it's better to err on the side of caution in these matters I guess.

Anyway, that was a thing.

Righto.

--
Adam (who wrote every word of this one. How bloody tedious)