From 8fba19fd54d5a605402fb7481d8ba9893369463c Mon Sep 17 00:00:00 2001
From: Chris Miles
Date: Sun, 23 Aug 2026 01:16:28 +0000
Subject: [PATCH 1/2] feat: add typed decode results
---
.github/workflows/test.yml | 2 +-
README.md | 23 +++++++++++++++++----
benchmark_test.go | 27 ++++++++++++++++++++++++
decode.go | 19 +++++++++++++++++
decode_test.go | 42 ++++++++++++++++++++++++++++++++++++++
docs/go.mod | 2 +-
examples/as/main.go | 21 +++++++++++++++++++
examples/go.mod | 2 +-
go.mod | 2 +-
9 files changed, 132 insertions(+), 8 deletions(-)
create mode 100644 examples/as/main.go
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f262bdf..dc90775 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- go-version: ["1.24.4", "stable"]
+ go-version: ["1.27.x", "stable"]
steps:
- name: Checkout
diff --git a/README.md b/README.md
index 82c0ba4..58e7dd5 100644
--- a/README.md
+++ b/README.md
@@ -10,11 +10,11 @@
-
+
-
+
@@ -34,7 +34,7 @@ The core contract is deliberately narrow:
## Installation
-execx requires Go 1.24.4 or newer.
+execx requires Go 1.27 or newer. Projects on older Go releases can pin the v1.1 release line.
```bash
go get github.com/goforj/execx
@@ -249,7 +249,7 @@ All public APIs are covered by runnable examples under `./examples`, and the tes
| **Construction** | [Command](#command) |
| **Context** | [WithContext](#withcontext) · [WithDeadline](#withdeadline) · [WithTimeout](#withtimeout) |
| **Debugging** | [Args](#args) · [ShellEscaped](#shellescaped) · [String](#string) |
-| **Decoding** | [Decode](#decode) · [DecodeJSON](#decodejson) · [DecodeWith](#decodewith) · [DecodeYAML](#decodeyaml) · [FromCombined](#fromcombined) · [FromStderr](#fromstderr) · [FromStdout](#fromstdout) · [Into](#into) · [Trim](#trim) |
+| **Decoding** | [As](#as) · [Decode](#decode) · [DecodeJSON](#decodejson) · [DecodeWith](#decodewith) · [DecodeYAML](#decodeyaml) · [FromCombined](#fromcombined) · [FromStderr](#fromstderr) · [FromStdout](#fromstdout) · [Into](#into) · [Trim](#trim) |
| **Environment** | [Env](#env) · [EnvAppend](#envappend) · [EnvInherit](#envinherit) · [EnvList](#envlist) · [EnvOnly](#envonly) |
| **Errors** | [Error](#error) · [Unwrap](#unwrap) |
| **Execution** | [CombinedOutput](#combinedoutput) · [OnExecCmd](#onexeccmd) · [Output](#output) · [OutputBytes](#outputbytes) · [OutputTrimmed](#outputtrimmed) · [Run](#run) · [Start](#start) |
@@ -360,6 +360,21 @@ fmt.Println(cmd.String())
## Decoding
+### As
+
+As executes the command and decodes its selected output into T.
+
+```go
+type payload struct {
+ Name string `json:"name"`
+}
+out, err := execx.Command("printf", `{"name":"gopher"}`).
+ DecodeJSON().
+ As[payload]()
+fmt.Println(err == nil, out.Name)
+// true gopher
+```
+
### Decode
Decode configures a custom decoder for this command.
diff --git a/benchmark_test.go b/benchmark_test.go
index 4393e21..195c64f 100644
--- a/benchmark_test.go
+++ b/benchmark_test.go
@@ -4,6 +4,9 @@ import "testing"
var benchmarkArgs []string
+// benchmarkPayload prevents decoded benchmark results from being optimized away.
+var benchmarkPayload testPayload
+
// BenchmarkCommandConstruction measures fluent argument assembly without subprocess noise.
func BenchmarkCommandConstruction(b *testing.B) {
b.ReportAllocs()
@@ -22,3 +25,27 @@ func BenchmarkShellEscaped(b *testing.B) {
benchmarkArgs = []string{cmd.ShellEscaped()}
}
}
+
+// BenchmarkDecodeResult compares caller-owned and generic result decoding through the same command path.
+func BenchmarkDecodeResult(b *testing.B) {
+ b.Run("Into", func(b *testing.B) {
+ b.ReportAllocs()
+ for b.Loop() {
+ var out testPayload
+ if err := Command("printf", `{"name":"gopher"}`).DecodeJSON().Into(&out); err != nil {
+ b.Fatalf("Into: %v", err)
+ }
+ benchmarkPayload = out
+ }
+ })
+ b.Run("As", func(b *testing.B) {
+ b.ReportAllocs()
+ for b.Loop() {
+ out, err := Command("printf", `{"name":"gopher"}`).DecodeJSON().As[testPayload]()
+ if err != nil {
+ b.Fatalf("As: %v", err)
+ }
+ benchmarkPayload = out
+ }
+ })
+}
diff --git a/decode.go b/decode.go
index 696863d..3393ba4 100644
--- a/decode.go
+++ b/decode.go
@@ -220,6 +220,25 @@ func (d *DecodeChain) Into(dst any) error {
return decodeInto(d.cmd, dst, d.decoder, d.cfg)
}
+// As executes the command and decodes its selected output into T.
+// @group Decoding
+//
+// Example: decode as a value
+//
+// type payload struct {
+// Name string `json:"name"`
+// }
+// out, err := execx.Command("printf", `{"name":"gopher"}`).
+// DecodeJSON().
+// As[payload]()
+// fmt.Println(err == nil, out.Name)
+// // true gopher
+func (d *DecodeChain) As[T any]() (T, error) {
+ var out T
+ err := decodeInto(d.cmd, &out, d.decoder, d.cfg)
+ return out, err
+}
+
// DecodeWith executes the command and decodes stdout into dst.
// @group Decoding
//
diff --git a/decode_test.go b/decode_test.go
index 21f77d2..d919f43 100644
--- a/decode_test.go
+++ b/decode_test.go
@@ -12,6 +12,48 @@ type testPayload struct {
Name string `json:"name"`
}
+// TestDecodeAs returns a typed result while preserving the configured decoder and source.
+func TestDecodeAs(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("decode as test uses printf")
+ }
+
+ as := (*DecodeChain).As[testPayload]
+ chain := Command("printf", ` {"name":"gopher"} `).
+ DecodeJSON().
+ FromStdout().
+ Trim()
+ out, err := as(chain)
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if out.Name != "gopher" {
+ t.Fatalf("unexpected name: %q", out.Name)
+ }
+
+ bound := Command("printf", `{"name":"value"}`).DecodeJSON().As[testPayload]
+ out, err = bound()
+ if err != nil || out.Name != "value" {
+ t.Fatalf("method value returned %+v, %v", out, err)
+ }
+}
+
+// TestDecodeAsErrors preserves command, decoder, and decode failures in the result form.
+func TestDecodeAsErrors(t *testing.T) {
+ if _, err := (*Cmd)(nil).DecodeJSON().As[testPayload](); err == nil {
+ t.Fatal("expected nil command error")
+ }
+ if _, err := Command("printf", `{}`).Decode(nil).As[testPayload](); err == nil {
+ t.Fatal("expected nil decoder error")
+ }
+ if runtime.GOOS == "windows" {
+ t.Skip("decode error test uses printf")
+ }
+ if _, err := Command("printf", `not-json`).DecodeJSON().As[testPayload](); err == nil {
+ t.Fatal("expected decode error")
+ }
+}
+
// TestDecodeYAMLInto ensures YAML command output can populate a caller-owned destination.
func TestDecodeYAMLInto(t *testing.T) {
if runtime.GOOS == "windows" {
diff --git a/docs/go.mod b/docs/go.mod
index 0de05d2..86bb2ae 100644
--- a/docs/go.mod
+++ b/docs/go.mod
@@ -1,3 +1,3 @@
module github.com/goforj/execx/docs
-go 1.24.4
+go 1.27.0
diff --git a/examples/as/main.go b/examples/as/main.go
new file mode 100644
index 0000000..756e41d
--- /dev/null
+++ b/examples/as/main.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+ "fmt"
+ "github.com/goforj/execx"
+)
+
+// main keeps this documented example executable so API drift fails during compilation.
+func main() {
+ // As executes the command and decodes its selected output into T.
+
+ // Example: decode as a value
+ type payload struct {
+ Name string `json:"name"`
+ }
+ out, err := execx.Command("printf", `{"name":"gopher"}`).
+ DecodeJSON().
+ As[payload]()
+ fmt.Println(err == nil, out.Name)
+ // true gopher
+}
diff --git a/examples/go.mod b/examples/go.mod
index 0c47b2e..50112ca 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -1,6 +1,6 @@
module github.com/goforj/execx/examples
-go 1.24.4
+go 1.27.0
require github.com/goforj/execx v1.1.0
diff --git a/go.mod b/go.mod
index 69aae6e..cb3af02 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/goforj/execx
-go 1.24.4
+go 1.27.0
require (
golang.org/x/term v0.40.0
From 4fcbcad01ee478bfa98eed154f2f61d5851285bd Mon Sep 17 00:00:00 2001
From: Chris Miles
Date: Sun, 23 Aug 2026 01:30:04 +0000
Subject: [PATCH 2/2] chore: tidy module requirements
---
go.mod | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/go.mod b/go.mod
index cb3af02..e6fe658 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,7 @@ module github.com/goforj/execx
go 1.27.0
require (
+ golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0
gopkg.in/yaml.v3 v3.0.1
)
-
-require golang.org/x/sys v0.41.0