Skip to content

Add Ask: keyed questions typed by their answer - #19

Merged
garretpremo merged 2 commits into
mainfrom
feat/typed-ask
Sep 25, 2026
Merged

garretpremo merged 2 commits into
mainfrom
feat/typed-ask

Conversation

@garretpremo

Copy link
Copy Markdown
Contributor

Closes #17.

What

Ask<A> pairs a key, a question, and the type of its answer. Declare it once, ask it, and read the answer back through it:

static final Ask<NoulAnswer> URGENT = Ask.noul("urgent", n -> n.instructions("Does `email` need a reply today?"));
static final Ask<ChoiceAnswer<Dept>> DEPT = Ask.choice("dept", Dept.class, c -> c
        .instructions("Which team should handle `email`?")
        .option(Dept.BILLING, "Invoices, refunds, payment methods"));

TypeSafeResponse response = client.systemOne(Map.of("email", email), URGENT, DEPT);
double urgent = response.answer(URGENT).noul();
Dept dept = response.answer(DEPT).choice();
  • Ask.noul / Ask.choice / Ask.score factories, as proposed in the issue.
  • Asks go in through systemOne / systemOneAsync(state, first, more...), TypeSafeRequest.of(state, first, more...), and TypeSafeRequest.Builder.ask(...). Per-call RequestOptions go through the builder form.
  • response.answer(ask) returns NoulAnswer, ChoiceAnswer<E>, or ScoreAnswer.

Answers to the issue's open questions

  1. Name: Ask.
  2. Noul answer type: Ask<NoulAnswer>, consistent with choice and score returning their answer records.
  3. CriteriaQuestionSet: unchanged; its keys are generated at runtime and stay on String keys.

Design

Ask is a sealed abstract class whose subtypes NoulAsk, ChoiceAsk<E>, and ScoreAsk mirror the question types:

  • Each subtype reads its own answer through a package-private read, so answer() has no unchecked casts.
  • Constructors are package-private, so the factories are the only way to create an ask.
  • question() is typed on each subtype (NoulAsk.question() is a Noul), and ChoiceAsk.labels() exposes the label type.
  • On Java 21+, a switch over an Ask is exhaustive.

We also compared a single final class and a sealed interface with records. The interface version needs a pattern switch in answer(), which doesn't compile at the SDK's Java 17 target, plus an unchecked cast. The final class can't expose the question's type or its labels.

Runtime checks

  • An enum ChoiceAsk rejects a label that isn't a constant of its enum when the ask is created. Before, the mismatch only surfaced when the answer was read (Choice's constructor accepts any label for any E).
  • A request rejects a second question under an asked key, in either order. Keys added without an Ask still replace each other, so existing behavior is unchanged.
  • A null key or question is rejected.
  • Reading an Ask that wasn't in the request fails like reading a missing key.
  • Asks compare by identity; this is documented.

Also fixed

  • Builder.state(key, value) now starts an object state when none is set, instead of throwing IllegalStateException. The issue's builder example relied on this.
  • With no questions, systemOne(state) doesn't compile, because the state-plus-asks overloads require at least one ask.

README

The Use section now leads with asks, and typed choices fold into a new Asks section. The string-keyed form moves to "Keyed questions", described as the form shared with the Python and JavaScript SDKs and the one to use for keys known only at runtime. The starter README's example now uses an ask.

The enum example now uses a switch expression. The old statement form's "a missing case is a compile error" comment wasn't true on Java 17: only a switch expression must cover every constant.

Testing

  • ./gradlew build passes: 61 SDK tests, 8 new, plus the Spring Boot starter.
  • The issue's snippet compiles unchanged. These misuses fail to compile: an enum ask given a Choice of a different enum, the String overload given an enum Choice, and reading answer(DEPT).choice() as another enum.
  • Every Java snippet in both READMEs compiles with javac --release 17.

An Ask pairs a key, a question, and the type of its answer, so the key
and enum are declared once and response.answer(ask) reads the answer
back typed: NoulAnswer, ChoiceAnswer<E>, or ScoreAnswer.

Ask is a sealed abstract class whose subtypes NoulAsk, ChoiceAsk, and
ScoreAsk mirror the question types. Each reads its own answer through a
package-private method, so answer() needs no unchecked cast, and the
factories are the only way to create one. An enum ChoiceAsk rejects a
label that is not a constant of its enum when it is created.

Asks go in through systemOne/systemOneAsync(state, asks...),
TypeSafeRequest.of(state, asks...), and TypeSafeRequest.Builder.ask.
A request rejects a second question under an asked key; keys added
without an Ask still replace each other.

Builder.state(key, value) now starts an object state when none is set.

Closes #17
The Use section now opens with asks: declare the questions once, ask
them, and read the answers back through them. Typed choices fold into a
new Asks section; the string-keyed form moves to Keyed questions, framed
as the form shared with the Python and JavaScript SDKs and for keys
known only at runtime. Async, per-call options, and the starter's
example read answers through asks.

The enum example uses a switch expression, since only an expression
has to cover every constant on Java 17.
@garretpremo
garretpremo merged commit f2f0200 into main Sep 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Typed answer lookup: read answers back through a keyed question handle

1 participant