Skip to content

The Java API

typecast-java is a first-class facade, not a wrapper afterthought: records, sealed interfaces, and Jakarta Bean Validation annotations, with no Scala types leaking through the API.

import typecast.javaapi.TypeCastClient;

TypeCastClient tc = TypeCastClient.builder()
    .transport(transport)          // any typecast.contract.SyncTransport
    .model("claude-haiku-4-5")     // required — model names are provider-scoped
    .retry(3)                      // targeted reprompts, 3 attempts total
    .build();

Invoice inv = tc.extract(Invoice.class, text); // throws TypedExtractionException on failure

Builder options: retry(maxAttempts), retry(maxAttempts, escalateModel, escalateAfter) (cheap model first, escalate after N failures), maxTotalTokens(n) (hard budget per extraction). Instances are immutable and as thread-safe as their transport.

Domain types: records and sealed interfaces

Schemas are derived at runtime by reflection and cached per class:

public sealed interface SupportAction permits Refund, Escalate, Reply {}
public record Refund(String orderId, @Positive BigDecimal amount) implements SupportAction {}
public record Escalate(@NotBlank String team, String reason)      implements SupportAction {}
public record Reply(String message)                               implements SupportAction {}
  • Records → objects: components become fields; component types may be records, enums, List<T>, Map<String, T>, Optional<T> (optional field), BigDecimal, java.time types, and primitives/boxed primitives.
  • Sealed interfaces of records → tagged unions, discriminated on "type" with the simple class name as the tag. The same fuzzy tag matching and discriminator inference as Scala unions applies on the way back.
  • Enums → closed string enums, fuzzy-matched at alignment.
  • Unsupported shapes fail with a descriptive IllegalArgumentException at first use.

Dispatch is exhaustive. On Java 21+ use the pattern switch:

return switch (tc.extract(SupportAction.class, ticket)) {
  case Refund r   -> process(r);
  case Escalate e -> route(e);
  case Reply m    -> send(m);
}; // no default needed: the compiler knows the sealed hierarchy is covered

On Java 17, pattern switch over sealed types still requires --enable-preview, so use the equivalent instanceof pattern chain (final Java 17 syntax, no flags) — the sealed hierarchy still guards the decision space at the type level:

SupportAction action = tc.extract(SupportAction.class, ticket);
if (action instanceof Refund r)        return process(r);
else if (action instanceof Escalate e) return route(e);
else if (action instanceof Reply m)    return send(m);
throw new IllegalStateException("unreachable: sealed");

Constraints: Jakarta Bean Validation

Annotations on record components lower to the same internal constraint IR as Scala's Iron refinements — driving schema keywords on the wire, post-hoc validation, and targeted retries:

Jakarta annotation IR Notes
@Min(n) / @Max(n) MinNumber(n) / MaxNumber(n) inclusive, per Jakarta semantics
@Positive / @Negative exclusive MinNumber(0) / MaxNumber(0)
@PositiveOrZero / @NegativeOrZero inclusive MinNumber(0) / MaxNumber(0)
@DecimalMin / @DecimalMax numeric bounds inclusive() respected
@Size(min, max) MinLength/MaxLength on strings, MinItems/MaxItems on lists
@Pattern(regexp) Pattern(regexp) flags() ignored (not expressible in the IR)
@Email Format(email)
@NotBlank MinLength(1) approximation: whitespace-only still passes the wire keyword; full check at validation
@NotEmpty MinLength(1) on strings, MinItems(1) on lists

A violated constraint is never visible to your code: it becomes a JSONPath-addressed validation error and a targeted reprompt, exactly as in Scala.

Failure handling

// Throwing style
try {
  Invoice inv = tc.extract(Invoice.class, text);
} catch (TypedExtractionException e) {
  log.warn(e.getMessage()); // rendered, JSONPath-addressed error
  log.debug(e.report());    // the full attempt-by-attempt transcript
}

// Non-throwing style
ExtractionResult<Invoice> result = tc.extractWithReport(Invoice.class, text);
if (result.isSuccess()) {
  Invoice inv = result.value().orElseThrow();
} else {
  String why = result.errorMessage();
}
String transcript = result.report(); // always available — the eval seam
int attempts = result.attempts();

Jackson interop

For codebases with existing Jackson models, JacksonBridge converts both directions between TypeCast's internal Js AST and JsonNode (numbers exchanged as BigDecimal, losslessly):

JsonNode node = JacksonBridge.toJsonNode(js);
Js js = JacksonBridge.fromJsonNode(node);

Transports from Java

Any typecast.contract.SyncTransport works — the Anthropic/OpenAI/Ollama transports from the Scala modules, or your own implementation (one method: send). Java-friendly factory methods and the LangChain4j adapter (typecast-langchain4j, reusing your app's existing ChatModel) are in progress for the launch.

Kotlin interop

Kotlin works through the same typecast.javaapi surface, with two verified findings (pinned by tests in examples-realworld/kotlin-triage/):

  • @JvmRecord data class + sealed interfaces fully satisfy the reflection contract on JVM target 17+ — union derivation and "type" discrimination work unchanged.
  • Jakarta constraints need explicit use-site targets in Kotlin: write @field:Positive, not @Positive. Kotlin's default annotation target is the constructor parameter, which record reflection never surfaces — a plain @Positive is silently ignored.

See examples-realworld/kotlin-triage/ for a complete Gradle project consuming the published artifacts.