goenv: Three Functions, One Env Var, Zero Fucks Given

Every service I write needs exactly one piece of information before it does anything else: is this thing running in prod, or is some poor bastard (me, at 2am, half a bottle in) still fucking around with it locally. That’s the entire requirement. For years the answer lived as os.Getenv("ENV") and a two-line if-statement, copy-pasted into every main.go I’ve ever written, spelled slightly differently each time. So I did what any reasonable engineer does when faced with a two-line problem: I wrote a whole package for it, gave it a README with a FAQ section, tagged two releases, and shipped it with full test coverage. This is goenv.

The Problem That Isn’t Really a Problem

There isn’t one, and that’s the entire joke. os.Getenv("ENV") is sixteen characters. Comparing it to "dev" is another eleven. You could write the whole check inline in less time than it took you to read this sentence. But then you’d have it inline, in every service, spelled a little differently every time — os.Getenv("ENVIRONMENT") here, APP_ENV there, someone checking for == "development" instead of "dev" in a third repo — and now your “am I in prod” logic is a minefield scattered across every service you own, each one wired slightly wrong in its own special way. goenv exists so that question has exactly one implementation, defined in exactly one place, imported everywhere it matters.

The Entire Implementation

Here’s the entire implementation. Not an excerpt — the whole file:

package goenv
import (
	"os"
)
const EnvVarName = "ENV"
type Type = string
const (
	Prod Type = "prod"
	Dev  Type = "dev"
)
func Get() Type {
	e := os.Getenv(EnvVarName)
	switch e {
	case Dev:
		return Dev
	default:
		return Prod
	}
}
func IsProd() bool {
	return Get() == Prod
}
func IsDev() bool {
	return Get() == Dev
}

One import. Not even fmt. A switch statement with a single real case and a default that always lands on Prod. Set ENV=dev and you get Dev back. Set it to anything else — prod, staging, test, an empty string, a typo, or nothing at all — and you get Prod. goenv doesn’t trust you to have spelled things correctly, so it doesn’t even try to guess. If it isn’t explicitly dev, it’s prod. No middle ground, no benefit of the doubt.

The exported surface is exactly three functions: Get() returns the current Type (“prod” or “dev”), and IsProd() / IsDev() are the two boolean conveniences that just call Get() and compare. Three constants back them up — EnvVarName (“ENV”), Prod, and Dev — plus a Type alias. And I want to be honest about that one, because it’s written type Type = string — with the equals sign, which makes it a genuine alias, not a defined type. It is interchangeable with string in every direction. You get exactly zero compile-time safety out of it; nothing stops you passing "banana" where a Type is expected. It’s documentation with extra steps, and pretending otherwise would be the kind of thing this package exists to make fun of. That’s the whole API. No config struct, no functional options, no interface to implement, no WithLogger(). Three functions, and you already know all of them.

Using It (There’s Not Much to Use)

go get github.com/psyb0t/goenv
package main
import (
	"fmt"
	"github.com/psyb0t/goenv"
)
func main() {
	if goenv.IsProd() {
		fmt.Println("don't fuck this up")
	}
	if goenv.IsDev() {
		fmt.Println("break whatever you want")
	}
}

And on the deployment side, it’s just an env var like every other one:

export ENV=dev   # you're developing, go break shit
export ENV=prod  # you're in production, don't
export ENV=      # also production, because paranoia is a feature

This is the exact trick servicepack uses for its own environment detection — same package, same ENV var, same “unless you explicitly say dev, you’re in prod” default. One dependency, one behavior, reused everywhere instead of reinvented per service.

What’s Actually in the Box

  • Zero dependenciesgo.mod is a module line and a Go version, nothing else. The only import anywhere in goenv.go is os.
  • Real test coverage, not a README boast — I pulled the source and ran go test -cover ./... myself: 100.0% of statements. Ten subtests across three test functions cover every branch — dev, prod, empty string, and an unrecognized value (“staging”) that also has to fall through to prod, because the tests check that the “anything not explicitly dev is prod” rule actually holds.
  • Defaults to prod, verifiably — it’s right there in the switch statement: the only path that returns Dev is an exact match on ENV=dev. Unset, empty, misspelled, “staging”, “test”, whatever — all of it resolves to Prod. The package doesn’t trust you and it’s not shy about it.
  • Three tagged releasesv1.0.0 (“fuck yeah prod or dev”), v1.0.1 (“peen”), and v1.0.2, which added a ClawHub agent skill. Yes. A package with three functions that reads one environment variable now ships a documented agent skill so an AI can be told how to call IsProd(). The skill file is longer than the library it documents. No library code changed in that release — goenv.go is untouched — which is somehow both the funniest and the most correct outcome available. All three real, all three pushed through the same CI pipeline as every other package I ship: go vet, go test -race, and a coverage gate set to 90% minimum in the Makefile — and as of v1.0.2 a tag-gated publish job that ships the skill to ClawHub after lint and tests pass — a bar this package clears by ten full points doing basically nothing.

The Bottom Line

If you need more than one bit of information out of your environment — types, defaults, structs, the works — go read about gonfiguration instead, that’s the grown-up version of this idea. goenv is the other end of that spectrum: it answers one question, answers it the same way every time, and doesn’t pretend to be anything more than that.

Grab it from GitHub, or write os.Getenv("ENV") == "dev" yourself like a normal person. Either way works. I’m not your boss.