Java 21’s records and sealed classes eliminate an entire class of LLM tool schema bugs. Instead of writing JSON schema strings by hand and discovering mismatches at the API call, you define your tool inputs and outputs as Java types and derive the schema from the types. The compiler catches structural errors before they reach the network.
Analysis Briefing
- Topic: Java 21 records and sealed classes applied to LLM tool schema generation
- Analyst: Mike D (@MrComputerScience)
- Context: A back-and-forth with Claude Sonnet 4.6 that went deeper than expected
- Source: Pithy Cyborg | Pithy Security
- Key Question: How do Java’s modern type features eliminate the tool schema bugs that appear at 2am in production?
Records as Tool Input Types
A Java record is a concise, immutable data carrier with automatically generated constructors, accessors, equals, hashCode, and toString. For LLM tool inputs, records are the correct abstraction: tool inputs are value types that the model constructs and you validate.
Define tool inputs as records with Jackson annotations for schema generation:
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
public record WebSearchInput(
@JsonProperty(required = true)
@JsonPropertyDescription("The search query to execute")
String query,
@JsonProperty(defaultValue = "5")
@JsonPropertyDescription("Maximum number of results to return (1-20)")
int maxResults,
@JsonPropertyDescription("Optional language filter, e.g. 'en', 'fr'")
String language
) {
// Compact constructor for validation
public WebSearchInput {
if (query == null || query.isBlank()) {
throw new IllegalArgumentException("query must not be blank");
}
maxResults = Math.clamp(maxResults, 1, 20);
}
}
The compact constructor runs on every instantiation, including when Jackson deserializes the model’s tool call arguments. Validation is automatic and centralized. You cannot receive a blank query or an out-of-range maxResults in your tool implementation.
Sealed Classes for Tool Result Discrimination
Tool results are discriminated unions: a call either succeeds with data or fails with an error. Sealed classes model this exactly.
public sealed interface ToolResult<T>
permits ToolResult.Success, ToolResult.Failure {
record Success<T>(T data, long executionMs) implements ToolResult<T> {}
record Failure<T>(String errorCode, String message, boolean retryable)
implements ToolResult<T> {}
static <T> ToolResult<T> success(T data, long executionMs) {
return new Success<>(data, executionMs);
}
static <T> ToolResult<T> failure(String errorCode, String message, boolean retryable) {
return new Failure<>(errorCode, message, retryable);
}
}
The sealed interface guarantees that every ToolResult is either a Success or a Failure. Pattern matching in switch expressions makes handling exhaustive and compiler-checked:
String formatForModel(ToolResult<SearchResults> result) {
return switch (result) {
case ToolResult.Success<SearchResults> s ->
"Found %d results in %dms: %s"
.formatted(s.data().results().size(), s.executionMs(),
s.data().toJson());
case ToolResult.Failure<SearchResults> f ->
"Search failed (%s): %s%s"
.formatted(f.errorCode(), f.message(),
f.retryable() ? " [retryable]" : "");
};
}
If you add a third permits type to the sealed interface later, every switch on ToolResult fails to compile until it handles the new case. Exhaustiveness is enforced at compile time, not discovered at runtime when a new case falls through to a default handler.
Schema Generation From Types
With records as your input types, JSON schema generation becomes a one-liner using Jackson’s schema module:
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.module.jsonSchema.jakarta.JsonSchemaGenerator;
public class ToolSchemaGenerator {
private final ObjectMapper mapper;
private final JsonSchemaGenerator schemaGen;
public ToolSchemaGenerator() {
this.mapper = new ObjectMapper();
this.schemaGen = new JsonSchemaGenerator(mapper);
}
public JsonNode generateSchema(Class<?> recordType) throws JsonMappingException {
return mapper.valueToTree(schemaGen.generateSchema(recordType));
}
}
Generate the schema at startup and cache it:
@Component
public class WebSearchTool {
private final JsonNode inputSchema;
public WebSearchTool(ToolSchemaGenerator schemaGenerator) throws JsonMappingException {
this.inputSchema = schemaGenerator.generateSchema(WebSearchInput.class);
}
public String name() { return "web_search"; }
public String description() { return "Search the web for current information."; }
public JsonNode inputSchema() { return inputSchema; }
public ToolResult<SearchResults> execute(WebSearchInput input) {
long start = System.currentTimeMillis();
try {
SearchResults results = performSearch(input.query(), input.maxResults());
return ToolResult.success(results, System.currentTimeMillis() - start);
} catch (Exception e) {
return ToolResult.failure("SEARCH_ERROR", e.getMessage(), true);
}
}
}
The schema in inputSchema() is always in sync with the actual WebSearchInput record. There is no separate schema definition that can drift. If you rename a field in the record, the schema updates automatically. If you add a required field to the record, the schema marks it required automatically.
Deserializing Model Tool Calls Back to Records
When the model returns a tool call, deserialize the arguments JSON directly to your record type:
public ToolResult<?> dispatch(String toolName, String argumentsJson) {
return switch (toolName) {
case "web_search" -> {
try {
var input = mapper.readValue(argumentsJson, WebSearchInput.class);
yield webSearchTool.execute(input);
} catch (JsonProcessingException e) {
yield ToolResult.failure("INVALID_ARGS",
"Failed to parse tool arguments: " + e.getMessage(), false);
}
}
case "read_file" -> {
try {
var input = mapper.readValue(argumentsJson, ReadFileInput.class);
yield readFileTool.execute(input);
} catch (JsonProcessingException e) {
yield ToolResult.failure("INVALID_ARGS",
"Failed to parse tool arguments: " + e.getMessage(), false);
}
}
default -> ToolResult.failure("UNKNOWN_TOOL",
"No tool registered for: " + toolName, false);
};
}
Jackson’s deserialization runs your record’s compact constructor validation automatically. A model call that produces a blank query field throws at deserialization time with a clear error, not at execution time with a confusing null pointer.
What This Means For You
- Define every tool input as a record with a compact constructor that validates the fields. Validation runs automatically on deserialization, eliminating an entire class of defensive null checks in your tool implementations.
- Use sealed interfaces for tool results so every call site handles both success and failure exhaustively. The compiler enforces coverage. You cannot accidentally ignore a failure case.
- Generate JSON schemas from your record types at startup, not by hand. The schema stays in sync with your types automatically. Drift between schema and implementation is not possible.
- Pattern match on sealed results in switch expressions rather than using
instanceofchains. The compiler verifies that every case is handled and will catch new cases you add later at compile time.
Enjoyed this deep dive? Join my inner circle:
- Pithy Cyborg → AI news made simple without hype.
- Pithy Security → Stay ahead of cybersecurity threats.
