gastownhall/beads · error
%s must be a list of strings, got %T
Error message
%s must be a list of strings, got %T
What it means
The default branch of mergeOpStrings fires when a merge-operation key's value is neither []string nor []interface{} — any other type is rejected. The library requires list-valued merge ops to be actual string lists so it can fold them into concrete column values against the pre-update row.
Source
Thrown at internal/storage/issueops/update.go:1007
func mergeOpStrings(op string, value interface{}, present bool) ([]string, error) {
if !present {
return nil, nil
}
switch v := value.(type) {
case []string:
return v, nil
case []interface{}:
out := make([]string, 0, len(v))
for _, item := range v {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("%s must be a list of strings, got element %T", op, item)
}
out = append(out, s)
}
return out, nil
default:
return nil, fmt.Errorf("%s must be a list of strings, got %T", op, value)
}
}
// readIssueAndResolveMergeOps reads the pre-update row in-transaction and folds
// any merge-operation keys (metadata edits, note appends) into concrete column
// values against that row, returning the row and the rewritten update map. It
// keeps the read-merge-write plumbing off updateIssueInTx's already-large body.
func readIssueAndResolveMergeOps(ctx context.Context, tx DBTX, id string, updates map[string]interface{}) (*types.Issue, map[string]interface{}, error) {
oldIssue, err := GetIssueInTx(ctx, tx, id)
if err != nil {
return nil, nil, fmt.Errorf("failed to get issue for update: %w", err)
}
resolved, err := ResolveMergeOps(oldIssue, updates)
if err != nil {
return nil, nil, err
}
return oldIssue, resolved, nil
}View on GitHub (pinned to 71377f2769)
Solutions
- Wrap scalar values in a single-element []string before adding them to the update map
- Check the op key spelling — a merge-op key set by mistake on a scalar field will be type-checked as a list
- If value comes from decoded JSON, assert the decoded type is an array of strings before calling Update
- Use a nil-check: nil values should either be omitted or sent via the regular column key, not a merge-op key
Example fix
// before
updates["_append_notes"] = "my note"
// after
updates["_append_notes"] = []string{"my note"} Defensive patterns
Strategy: validation
Validate before calling
func isStringSliceOrWrap(v interface{}) ([]string, bool) {
switch l := v.(type) {
case []string:
return l, true
case string:
return []string{l}, true
case nil:
return nil, false
}
return nil, false
} Type guard
if _, ok := updates[OpAppendNotes].([]string); !ok { return fmt.Errorf("%s requires a []string", OpAppendNotes) } Prevention
- Always pass merge-op values as []string (or []interface{} of strings)
- Check op-key spelling so scalar fields are not misread as list merge ops
- Add a small helper that normalizes scalars into single-element slices before Update
When it happens
Trigger: Passing a scalar (string, int, map[string]interface{}, nil) as the value of OpMergeMetadata/OpSetMetadata/OpUnsetMetadata/OpAppendNotes in the updates map handed to ResolveMergeOps, updateIssueInTx, or storage Update.
Common situations: Client sends a single string instead of an array (e.g. "labels": "x" instead of ["x"]); a nil slip-through from an optional field; wrong op key mapping causing a scalar field value to be treated as a merge op.
Related errors
- %s must be a list of strings, got element %T
- %w: invalid issue type %v
- metadata must be string, []byte, or json.RawMessage, got %T
- no store is open for this workspace
- not found
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/8d7705e70930ad9d.
Report an issue: GitHub.