All posts
10 March 2026·4 min read

Building a Go CLI as a TypeScript Engineer

What I learned building dlqctl — a dead-letter queue manager — as my first serious Go project, coming from years of TypeScript and Node.js.

Go
TypeScript
CLI
Developer Tooling

After 10+ years of writing TypeScript and Node.js professionally, I decided to learn Go by building something real: dlqctl, a CLI tool for managing dead-letter queues across SQS and Kafka.

This isn't a "Go vs TypeScript" post. It's what actually surprised me, what was easier than expected, and where my TypeScript muscle memory actively fought me.

Why Go, and why a CLI

I chose Go because the backend infrastructure space — the space I work in — runs on it. Kubernetes, Terraform, Docker, the AWS CLI: they're all Go. If I want to contribute to or build tools in that ecosystem, I need to speak the language.

A CLI was the right first project because it's small enough to finish, complex enough to learn from, and removes the web framework distraction. No HTTP routing debates, no middleware stacks. Just stdin, stdout, and your logic.

The things that clicked immediately

Error handling. I know, everyone complains about if err != nil. But coming from TypeScript where errors are unknown and you're wrapping everything in try-catch or building Result types — Go's explicit error returns felt honest. You can't accidentally ignore an error. The compiler won't let you.

result, err := client.ReceiveMessage(ctx, input)
if err != nil {
    return fmt.Errorf("receiving messages from %s: %w", queueURL, err)
}

No magic. No middleware. Just handle it.

Goroutines for concurrent processing. Processing DLQ messages concurrently in TypeScript means Promise.all with careful batching to avoid overwhelming your event loop. In Go, you spin up goroutines and use channels:

sem := make(chan struct{}, maxConcurrency)
var wg sync.WaitGroup

for _, msg := range messages {
    wg.Add(1)
    sem <- struct{}{}
    go func(m Message) {
        defer wg.Done()
        defer func() { <-sem }()
        processMessage(m)
    }(msg)
}

wg.Wait()

The concurrency model maps more naturally to infrastructure tooling where you're often doing many I/O operations in parallel.

Where TypeScript habits fought me

Reaching for interfaces too early. In TypeScript, you define an interface for everything. In Go, interfaces are satisfied implicitly and are conventionally small — often just one or two methods. I kept creating large interfaces upfront and had to unlearn that.

Package structure. Node.js lets you organise however you want. Go has strong conventions about package layout and the relationship between directories and package names. I reorganised the project three times before it felt right.

No generics muscle memory. Go 1.18+ has generics, but the ecosystem doesn't use them as pervasively as TypeScript. I kept wanting to write generic utility functions and had to accept that concrete types are the Go way for most things.

Cobra and Viper are excellent

The Cobra/Viper combination for CLI development is genuinely best-in-class. Cobra gives you subcommands, flags, argument validation, and shell completions. Viper handles configuration from files, environment variables, and flags with priority ordering.

My dlqctl has commands like:

dlqctl inspect --queue my-dlq --region eu-west-2
dlqctl replay --queue my-dlq --target my-main-queue --concurrency 10
dlqctl purge --queue my-dlq --dry-run

Building this in Node.js would have meant choosing between commander, yargs, or oclif, then separately handling config. Cobra + Viper is the integrated, batteries-included answer.

GoReleaser for distribution

This was the biggest surprise. GoReleaser cross-compiles your binary for every platform, creates GitHub releases, generates checksums, and builds Homebrew formulae — all from a single YAML config and a git tag.

In the Node.js world, distributing a CLI means either npm publish (requires Node on the target machine) or bundling with pkg/nexe (fragile). Go produces a single static binary. Your user downloads it, puts it in their PATH, and it works. No runtime dependencies.

Would I use Go at work?

For CLI tools and infrastructure services, absolutely. For a REST API that mainly shuffles JSON between a database and a frontend, TypeScript/Node.js is still faster to iterate on.

The right answer is knowing when to reach for each. Go taught me to think more carefully about concurrency, errors, and simplicity. TypeScript taught me to move fast with types as guardrails. They're complementary, not competing.


dlqctl is open source at github.com/Ahmad-Saleem. Contributions welcome.