Skip to content

Use Optional return types instead of nullable boxed primitives in public API #187

@edburns

Description

@edburns

Summary

The public API of this SDK uses nullable boxed types (Boolean, Integer, Double) on mutable config/builder classes to represent optional values. This is a C# idiom — not a Java one. Java has had Optional<T>, OptionalInt, and OptionalDouble since Java 8 (2014), and returning Optional from getters is the established idiomatic way to express "this value may be absent."

Today the SDK has ~30 public getters on mutable classes returning nullable boxed types and zero uses of Optional anywhere in non-generated source code. This is a clear signal that the nullable-return convention was carried over from the C# reference implementation rather than adapted to Java idioms during the port.

Scope

What to change

Only mutable config/builder classes (regular classes with hand-written getters/setters). These classes all have tri-state semantics where null means "use the server/runtime default" — a contract that is invisible in the current type signatures but becomes explicit with Optional.

What NOT to change

  • Record types — Record accessor methods are auto-generated from components. Optional as a record component is unidiomatic and clashes with Jackson deserialization. Records like PingResponse, ToolDefinition, PreToolUseHookOutput, PostToolUseHookOutput, SessionEndHookOutput, and UserPromptSubmittedHookOutput are explicitly out of scope.
  • Generated code under src/generated/java/ — do not modify.
  • Internal fields — Fields remain as nullable boxed types for Jackson serialization (@JsonInclude(NON_NULL)). Only the getter return types and setter parameter types change.

Specification

For each affected getter/setter pair, apply this transformation:

For Boolean fields

Before:

private Boolean enableSessionTelemetry;

public Boolean getEnableSessionTelemetry() {
    return enableSessionTelemetry;
}

public SessionConfig setEnableSessionTelemetry(Boolean enableSessionTelemetry) {
    this.enableSessionTelemetry = enableSessionTelemetry;
    return this;
}

After:

private Boolean enableSessionTelemetry;

public Optional<Boolean> getEnableSessionTelemetry() {
    return Optional.ofNullable(enableSessionTelemetry);
}

public SessionConfig setEnableSessionTelemetry(boolean enableSessionTelemetry) {
    this.enableSessionTelemetry = enableSessionTelemetry;
    return this;
}

public SessionConfig clearEnableSessionTelemetry() {
    this.enableSessionTelemetry = null;
    return this;
}

For Integer fields

Before:

private Integer sessionIdleTimeoutSeconds;

public Integer getSessionIdleTimeoutSeconds() {
    return sessionIdleTimeoutSeconds;
}

public CopilotClientOptions setSessionIdleTimeoutSeconds(Integer sessionIdleTimeoutSeconds) {
    this.sessionIdleTimeoutSeconds = sessionIdleTimeoutSeconds;
    return this;
}

After:

private Integer sessionIdleTimeoutSeconds;

public OptionalInt getSessionIdleTimeoutSeconds() {
    return sessionIdleTimeoutSeconds == null ? OptionalInt.empty() : OptionalInt.of(sessionIdleTimeoutSeconds);
}

public CopilotClientOptions setSessionIdleTimeoutSeconds(int sessionIdleTimeoutSeconds) {
    this.sessionIdleTimeoutSeconds = sessionIdleTimeoutSeconds;
    return this;
}

public CopilotClientOptions clearSessionIdleTimeoutSeconds() {
    this.sessionIdleTimeoutSeconds = null;
    return this;
}

For Double fields

Same pattern, using OptionalDouble and primitive double setter.

Affected classes and fields

Public API config classes (user-facing)

CopilotClientOptions.java

Getter Current return New return Setter param
getSessionIdleTimeoutSeconds() Integer OptionalInt int
getUseLoggedInUser() Boolean Optional<Boolean> boolean

SessionConfig.java

Getter Current return New return Setter param
getEnableSessionTelemetry() Boolean Optional<Boolean> boolean
getEnableConfigDiscovery() Boolean Optional<Boolean> boolean
getIncludeSubAgentStreamingEvents() Boolean Optional<Boolean> boolean

ResumeSessionConfig.java

Getter Current return New return Setter param
getEnableSessionTelemetry() Boolean Optional<Boolean> boolean
getEnableConfigDiscovery() Boolean Optional<Boolean> boolean
getIncludeSubAgentStreamingEvents() Boolean Optional<Boolean> boolean

InfiniteSessionConfig.java

Getter Current return New return Setter param
getEnabled() Boolean Optional<Boolean> boolean
getBackgroundCompactionThreshold() Double OptionalDouble double
getBufferExhaustionThreshold() Double OptionalDouble double

InputOptions.java

Getter Current return New return Setter param
getMinLength() Integer OptionalInt int
getMaxLength() Integer OptionalInt int

ModelCapabilitiesOverride.Supports (inner class)

Getter Current return New return Setter param
getVision() Boolean Optional<Boolean> boolean
getReasoningEffort() Boolean Optional<Boolean> boolean

ModelCapabilitiesOverride.Limits (inner class)

Getter Current return New return Setter param
getMaxPromptTokens() Integer OptionalInt int
getMaxOutputTokens() Integer OptionalInt int
getMaxContextWindowTokens() Integer OptionalInt int

ProviderConfig.java

Getter Current return New return Setter param
getMaxPromptTokens() Integer OptionalInt int
getMaxOutputTokens() Integer OptionalInt int

TelemetryConfig.java

Getter Current return New return Setter param
getCaptureContent() Boolean Optional<Boolean> boolean

SessionUiCapabilities.java

Getter Current return New return Setter param
getElicitation() Boolean Optional<Boolean> boolean

CustomAgentConfig.java

Getter Current return New return Setter param
getInfer() Boolean Optional<Boolean> boolean

UserInputRequest.java

Getter Current return New return Setter param
getAllowFreeform() Boolean Optional<Boolean> boolean

Internal wire DTOs (not directly user-facing, but same pattern applies)

CreateSessionRequest.java

Getter Current return New return Setter param
getEnableSessionTelemetry() Boolean Optional<Boolean> boolean
getRequestPermission() Boolean Optional<Boolean> boolean
getRequestUserInput() Boolean Optional<Boolean> boolean
getHooks() Boolean Optional<Boolean> boolean
getStreaming() Boolean Optional<Boolean> boolean
getEnableConfigDiscovery() Boolean Optional<Boolean> boolean
getIncludeSubAgentStreamingEvents() Boolean Optional<Boolean> boolean
getRequestElicitation() Boolean Optional<Boolean> boolean

ResumeSessionRequest.java

Getter Current return New return Setter param
getEnableSessionTelemetry() Boolean Optional<Boolean> boolean
getRequestPermission() Boolean Optional<Boolean> boolean
getRequestUserInput() Boolean Optional<Boolean> boolean
getHooks() Boolean Optional<Boolean> boolean
getEnableConfigDiscovery() Boolean Optional<Boolean> boolean
getDisableResume() Boolean Optional<Boolean> boolean
getStreaming() Boolean Optional<Boolean> boolean
getIncludeSubAgentStreamingEvents() Boolean Optional<Boolean> boolean
getRequestElicitation() Boolean Optional<Boolean> boolean

McpServerConfig.java (abstract base)

Getter Current return New return Setter param
getTimeout() Integer OptionalInt int

ModelLimits.java

Getter Current return New return Setter param
getMaxPromptTokens() Integer OptionalInt int

Caller updates required

All internal callers that null-check these getters must be updated to use Optional APIs. Common patterns:

// BEFORE (e.g., CliServerManager.java)
if (options.getSessionIdleTimeoutSeconds() != null) {
    args.add("--session-idle-timeout");
    args.add(String.valueOf(options.getSessionIdleTimeoutSeconds()));
}

// AFTER
options.getSessionIdleTimeoutSeconds().ifPresent(timeout -> {
    args.add("--session-idle-timeout");
    args.add(String.valueOf(timeout));
});
// BEFORE (e.g., CliServerManager.java)
if (options.getUseLoggedInUser() != null && options.getUseLoggedInUser()) {
    args.add("--use-logged-in-user");
}

// AFTER
options.getUseLoggedInUser().filter(v -> v).ifPresent(v ->
    args.add("--use-logged-in-user")
);
// BEFORE (e.g., SessionRequestBuilder.java)
Boolean value = config.getEnableSessionTelemetry();
if (value != null) {
    request.setEnableSessionTelemetry(value);
}

// AFTER
config.getEnableSessionTelemetry().ifPresent(request::setEnableSessionTelemetry);

Jackson serialization

No changes needed to Jackson configuration. The internal fields remain as nullable boxed types (Boolean, Integer, Double) with @JsonInclude(NON_NULL). Jackson reads/writes the fields directly — the Optional wrapper exists only on the getter return type, not on the field or in the serialized JSON.

Testing

  • All existing tests must continue to pass after the changes.
  • The clearXxx() methods should be exercised in tests to verify they reset the field to null (and that Jackson then omits the field from serialized output).
  • Callers updated from null-check patterns to Optional patterns should produce identical behavior.

Why this matters

Dimension Current (nullable boxed) Proposed (Optional)
Nullability visible in type signature? No Yes
Forgotten null-check consequence Compiles, NPEs at runtime Does not compile
Idiomatic Java? No (C# convention) Yes (standard since Java 8)
Optional usage in SDK today 0 instances
Records affected? None (explicitly excluded)

This is a breaking API change for getter return types and should be planned accordingly.

Metadata

Metadata

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions