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

  1. Wrap scalar values in a single-element []string before adding them to the update map
  2. Check the op key spelling — a merge-op key set by mistake on a scalar field will be type-checked as a list
  3. If value comes from decoded JSON, assert the decoded type is an array of strings before calling Update
  4. 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

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


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/8d7705e70930ad9d. Report an issue: GitHub.