Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,37 @@ cfg.LoadConfigFromEnv()
cfg, err := basecamp.LoadConfig("/path/to/config.json")
```

## Optional Fields

Optional fields are pointers, so that "not addressed" stays distinguishable from
a value. Nil omits the field; a non-nil pointer sends the value verbatim,
including the zero value. `basecamp.Ptr` builds one for any type:

```go
entry, err := account.Schedules().UpdateEntry(ctx, entryID, &basecamp.UpdateScheduleEntryRequest{
Summary: basecamp.Ptr("Kickoff, moved"),
AllDay: basecamp.Ptr(false), // an explicit false, not "unset"
ParticipantIDs: basecamp.Ptr([]int64{}), // an explicit empty list: remove everyone
// Description stays nil, so the entry's description is left alone.
})
```

Reading one is the half that fails quietly: Go auto-dereferences a value-receiver
method call, so `hc.UpdatedAt.IsZero()` compiles against a `*time.Time` and
panics at run time on a chart that has never moved. Nil-check it, or let
`basecamp.Deref` return the zero value for you:

```go
hc, err := account.HillCharts().Get(ctx, todosetID)
if updated := basecamp.Deref(hc.UpdatedAt); !updated.IsZero() {
fmt.Println("last moved", updated)
}
```

Collapsing absence to the zero value is only safe where the caller cannot tell
the two apart. Where the difference carries meaning — a string the server really
sent as empty versus a field it omitted — compare against nil instead.

## API Coverage

### Projects & Organization
Expand Down
21 changes: 21 additions & 0 deletions go/pkg/basecamp/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,27 @@
// - [AccountClient.Attachments] - File attachments
// - [Client.Authorization] - Account-agnostic authorization info
//
// # Optional Fields
//
// Optional fields are pointers, so that "not addressed" stays distinguishable
// from a value. Nil omits the field; a non-nil pointer sends the value
// verbatim, including the zero value. [Ptr] builds one for any type:
//
// entry, err := account.Schedules().UpdateEntry(ctx, entryID, &basecamp.UpdateScheduleEntryRequest{
// Summary: basecamp.Ptr("Kickoff, moved"),
// AllDay: basecamp.Ptr(false), // an explicit false, not "unset"
// ParticipantIDs: basecamp.Ptr([]int64{}), // an explicit empty list: remove everyone
// })
//
// Reading one is the half that fails quietly: Go auto-dereferences a
// value-receiver method call, so hc.UpdatedAt.IsZero() compiles against a
// *time.Time and panics at run time on a chart that has never moved. Nil-check
// it, or let [Deref] return the zero value for you:
//
// if updated := basecamp.Deref(hc.UpdatedAt); !updated.IsZero() {
// fmt.Println("last moved", updated)
// }
//
// # Working with Projects
//
// List all projects:
Expand Down
42 changes: 42 additions & 0 deletions go/pkg/basecamp/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package basecamp_test

import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"time"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
)
Expand Down Expand Up @@ -403,3 +405,43 @@ func ExampleCampfiresService_CreateLine() {

fmt.Printf("Message posted: %s\n", line.Content)
}

func ExamplePtr() {
// Optional request fields are pointers so that "not addressed" stays
// distinguishable from a value. Ptr sets one; leaving it nil omits it.
req := &basecamp.UpdateScheduleEntryRequest{
Summary: basecamp.Ptr("Kickoff, moved"),
AllDay: basecamp.Ptr(false), // an explicit false, not "unset"
ParticipantIDs: basecamp.Ptr([]int64{}), // an explicit empty list: remove everyone
// Description stays nil, so the entry's description is left alone.
}

body, err := json.Marshal(req)
if err != nil {
log.Fatal(err)
}

fmt.Println(string(body))
// Output: {"summary":"Kickoff, moved","all_day":false,"participant_ids":[]}
}

func ExampleDeref() {
// A hill chart that has never moved omits updated_at, so UpdatedAt is nil.
// Calling hc.UpdatedAt.IsZero() directly compiles and panics; Deref is total.
never := &basecamp.HillChart{Enabled: true}
moved := &basecamp.HillChart{
Enabled: true,
UpdatedAt: basecamp.Ptr(time.Date(2026, 8, 3, 9, 30, 0, 0, time.UTC)),
}

for _, hc := range []*basecamp.HillChart{never, moved} {
if updated := basecamp.Deref(hc.UpdatedAt); updated.IsZero() {
fmt.Println("never moved")
} else {
fmt.Println("last moved", updated.Format(time.RFC3339))
}
}
// Output:
// never moved
// last moved 2026-08-03T09:30:00Z
}
16 changes: 10 additions & 6 deletions go/pkg/basecamp/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,12 +289,13 @@ func truncate(s string) string {

// deref safely dereferences an optional-field pointer, returning the zero
// value when the field was absent.
//
// The internal spelling of the exported [Deref], kept as the vocabulary the
// hundreds of existing conversion sites already read in. Forwarding rather than
// reimplementing keeps one definition, so the contract callers get cannot drift
// from the contract this package relies on.
func deref[T any](p *T) T {
if p == nil {
var zero T
return zero
}
return *p
return Deref(p)
}

// omitzero converts a value-typed wrapper option to a generated request's
Expand All @@ -310,8 +311,11 @@ func omitzero[T comparable](v T) *T {

// ptr returns a pointer to v, for optional fields where the value — zero
// included — must be sent.
//
// The internal spelling of the exported [Ptr]; see deref for why it forwards
// rather than reimplements.
func ptr[T any](v T) *T {
return &v
return Ptr(v)
}

// intPtrFrom converts an optional generated int32 pointer to the SDK's *int,
Expand Down
56 changes: 56 additions & 0 deletions go/pkg/basecamp/pointers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package basecamp

// Optional fields on this SDK's request and response types are pointers, so
// that absence stays distinguishable from a value (SPEC.md §10): nil means the
// field was not addressed, and a non-nil pointer means this exact value — the
// zero value included. Go has no literal syntax for the address of a constant,
// so writing to those fields otherwise costs a named variable each, and reading
// from them silently compiles into a nil dereference. Ptr and Deref are the two
// halves of that round trip.

// Ptr returns a pointer to v, for setting an optional field.
//
// A nil optional field is omitted from the request; a non-nil one is sent
// verbatim. Ptr therefore always allocates, and never collapses false or "" to
// nil — sending an explicit zero is the whole reason these fields are pointers.
//
// Being generic over every type, one helper covers the scalar fields and the
// pointer-to-slice fields alike:
//
// entry, err := account.Schedules().UpdateEntry(ctx, entryID, &basecamp.UpdateScheduleEntryRequest{
// Summary: basecamp.Ptr("Kickoff, moved"),
// AllDay: basecamp.Ptr(false), // an explicit false, not "unset"
// ParticipantIDs: basecamp.Ptr([]int64{}), // an explicit empty list: remove everyone
// })
//
// T is inferred from the argument, so a field whose type is not an untyped
// literal's default needs the conversion written out: basecamp.Ptr(int32(5))
// for an *int32 field, not basecamp.Ptr(5).
func Ptr[T any](v T) *T {
return &v
}

// Deref returns the value p points at, or the zero value of T when p is nil.
//
// Reading an optional field is the half that fails quietly. Go auto-dereferences
// a value-receiver method call, so hc.UpdatedAt.IsZero() still compiles against
// a *time.Time and panics at run time on a hill chart that has never moved.
// Deref is total, and makes the absent case an ordinary value:
//
// hc, err := account.HillCharts().Get(ctx, todosetID)
// // ...
// if updated := basecamp.Deref(hc.UpdatedAt); !updated.IsZero() {
// fmt.Println("last moved", updated)
// }
//
// Collapsing absence to the zero value is only correct where the caller cannot
// tell the two apart anyway. Where the difference carries meaning — a string the
// server really sent as empty versus a field it omitted — compare against nil
// instead.
func Deref[T any](p *T) T {
if p == nil {
var zero T
return zero
}
return *p
}
Loading
Loading