typesafe-sdk-go

September 19, 2026 · View on GitHub

Go Reference Go 1.25+ MIT

typesafe-sdk-go is a Go SDK for the TypeSafe AI API.

TypeSafe answers typed questions about a piece of state and returns probability distributions, not prose. There is nothing to parse and no format to coax out of a model.

Install the module with:

go get github.com/Tangerg/typesafe-sdk-go@latest

It needs Go 1.25 or newer and has no third-party dependencies.

The package is named typesafe, so an import needs no alias in most editors but reads better with one:

import typesafe "github.com/Tangerg/typesafe-sdk-go"

Quickstart

Set TYPESAFE_API_KEY in your environment, then:

package main

import (
	"context"
	"fmt"
	"log"

	typesafe "github.com/Tangerg/typesafe-sdk-go"
)

func main() {
	client, err := typesafe.NewClient(nil) // reads TYPESAFE_API_KEY
	if err != nil {
		log.Fatal(err)
	}

	result, err := client.SystemOne(context.Background(), &typesafe.SystemOneRequest{
		State: "I was charged twice. Please fix this ASAP.",
		Questions: typesafe.Questions{
			"category": &typesafe.ChoiceQuestion{
				Instructions: "What is this ticket about?",
				Criteria: typesafe.ChoiceCriteria{
					"billing":   nil,
					"technical": nil,
					"other":     nil,
				},
			},
		},
	}, nil)
	if err != nil {
		log.Fatal(err)
	}

	category, err := result.Answers.Choice("category")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(category.Choice, category.Confidence)
}

There is a runnable version in examples/demo:

TYPESAFE_API_KEY=sk-... go run ./examples/demo

Questions and answers

Three question types, mixable in one call. Each is evaluated in parallel and in isolation against the same state, so a question never sees another's answer. Keep each one atomic and combine them in your own code.

QuestionAsksAnswer
NoulQuestionsomething yes-or-noNoul — the probability of yes
ChoiceQuestionwhich of these labelsChoice, Confidence, Probabilities
ScoreQuestionwhere on this rubricScore, Confidence, Legend, Probabilities
result, err := client.SystemOne(ctx, &typesafe.SystemOneRequest{
	State: ticket,
	Questions: typesafe.Questions{
		"isBilling": &typesafe.NoulQuestion{Instructions: "Is this ticket about billing?"},
		"tone": &typesafe.ChoiceQuestion{
			Instructions: "What is the customer's tone?",
			Criteria:     typesafe.ChoiceCriteria{"calm": nil, "frustrated": nil, "angry": nil},
		},
		"urgency": &typesafe.ScoreQuestion{
			Instructions: "How urgent is this ticket?",
			Criteria:     typesafe.ScoreCriteria{"can wait", "this week", "today", "right now"},
		},
	},
}, nil)

A ChoiceCriteria value of nil leaves a label undescribed, which is the right choice when the label speaks for itself. A ScoreCriteria is a list, and its order is its meaning: position zero is the lowest score.

The service's own rules are checked before a request is sent, so they cost no round trip:

Rule
State is requiredJSON null, numbers, and booleans are refused; "", {} and [] are states
A question name may not be emptyit is the key an answer comes back under
A NoulQuestion needs Instructions or Criterianonempty instructions or at least one non-null criterion
ChoiceCriteria1 to 255 labels
ScoreCriteria1 to 10 levels, each non-null
Content entriestext, object, array, or null; nested numbers and booleans are allowed

These rules were checked against the live API on 2026-09-20. The service accepts a one-level score (always zero) and rejects null score levels, although the API reference and TypeScript SDK describe a two-level minimum, and the TypeScript SDK allows null levels. This SDK follows the service. Empty noul instructions require a criterion; an empty string, object, or array supplied as that criterion is accepted.

Call Validate() on a SystemOneRequest or on Questions to check them yourself when a request is assembled far from where it is sent.

Validation checks the encoded JSON, including typed nils and custom json.Marshaler values. SystemOne encodes once and sends the bytes it checked; Validate() performs its own encoding and also checks Extra collisions.

A question's type decides its answer's, but Go cannot vary a map's value type by key, so the type is named at the point of use:

isBilling, err := result.Answers.Noul("isBilling")   // *NoulAnswer
tone, err := result.Answers.Choice("tone")           // *ChoiceAnswer
urgency, err := result.Answers.Score("urgency")      // *ScoreAnswer

Each accessor reports a clear error when the answer is absent or has a different type than you asked for.

An answer carries the readings you would otherwise compute from it:

if isBilling.Noul > 0.8 {        // a noul is a probability; the threshold is yours
	route(ticket)
}
tone.Choice                      // the selected label
tone.Probability()               // how likely that label was — not Confidence
urgency.Score                    // expected score, which may fall between levels
urgency.Level()                  // the rubric level it rounds to
urgency.Description()            // what the rubric says about that level

NoulAnswer has no Yes() on purpose: a threshold depends on what a wrong yes and a wrong no cost you, and that is the one part of the decision this SDK cannot know.

Configuration

NewClient takes its settings from ClientOptions, then from the environment, then from the SDK defaults. A blank environment value counts as unset.

OptionEnvironmentDefault
APIKeyTYPESAFE_API_KEYrequired
BaseURLTYPESAFE_BASE_URLhttps://api.typesafe.ai
DefaultModelTYPESAFE_DEFAULT_MODELjev-latest
LoggerTYPESAFE_LOG_LEVELno logging
Timeout10s per attempt
RetryDefaultRetryPolicy()
Headernone
HTTPClienthttp.DefaultClient

The SDK copies the HTTP client's settings and shares its transport and cookie jar. By default, redirects return an *APIError with the original 3xx status: following a redirect could forward credentials or evaluation data, or change a POST into a GET. An explicitly supplied HTTPClient.CheckRedirect policy is honored.

A Client is safe for concurrent use and pools its connections. Create one for the life of the program and share it.

BaseURL must be an absolute HTTP(S) root, optionally with a path prefix. Credentials, query strings, and fragments are rejected at construction.

Timeouts and retries

Timeout bounds each attempt, not the call as a whole. Retries have no budget of their own, so bound a whole call with its context:

ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

DefaultRetryPolicy() retries 408, 429, and every 5xx, along with connection failures and timeouts, twice each: exponential backoff from 500ms to 5s with up to 25% jitter, honoring a Retry-After header within a minute.

A RetryPolicy is a complete setting rather than a patch, so a zero field is never mistaken for an unset one. Start from the defaults and change what you need:

policy := typesafe.DefaultRetryPolicy()
policy.MaxRetries = 5
client, err := typesafe.NewClient(&typesafe.ClientOptions{Retry: &policy})

A nil Retry in ClientOptions or RequestOptions inherits the level above it; a non-nil one replaces it outright.

Errors

result, err := client.SystemOne(ctx, request, nil)
switch {
case errors.Is(err, typesafe.ErrRateLimit):
	// back off and try later
case errors.Is(err, typesafe.ErrAuthentication):
	// check TYPESAFE_API_KEY
case errors.Is(err, context.Canceled):
	// the caller gave up
case err != nil:
	var apiErr *typesafe.APIError
	if errors.As(err, &apiErr) {
		log.Printf("request %s failed: %v", apiErr.RequestID, apiErr.Body)
	}
}
  • *APIError — a response the service refused to fulfil. Match its class with ErrBadRequest, ErrAuthentication, ErrPermissionDenied, ErrNotFound, ErrUnprocessableEntity, ErrRateLimit, and ErrServerError (any 5xx) rather than comparing status codes.
  • *ConnectionError — a request that produced no complete response. One that ran out of time also matches context.DeadlineExceeded.
  • The context's own error when the caller gives up, so context.Canceled means your cancellation and nothing else. A custom cancellation cause remains available through errors.Is alongside context.Canceled or context.DeadlineExceeded.

Logging

Pass an *slog.Logger to log activity: summaries at info, full headers and bodies at debug. Credential headers are redacted; bodies are not, and yours may hold the data you are evaluating.

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
client, err := typesafe.NewClient(&typesafe.ClientOptions{Logger: logger})

With no logger the SDK is silent, unless TYPESAFE_LOG_LEVEL is set — which exists so a deployed program can be made to explain itself without a rebuild.

Raw responses

Every result carries a Meta with the request ID, status, headers, and the raw response body. The typed fields drop any property the SDK does not model, and Meta.Body is where to find one:

fmt.Println(result.Meta.RequestID)
fmt.Println(string(list.Meta.Body))

To send a request property the SDK does not model yet, use SystemOneRequest.Extra.

Testing

Unit tests need no credentials and never reach the network:

go test ./...

They run against payloads the live service actually sent, captured under testdata/. A fixture that stops matching is the service telling you its shape changed; refresh the file and read the diff.

Integration tests call the real API, so they are behind a build tag and cost real requests:

export TYPESAFE_API_KEY=sk-...
go test -tags integration -v ./...

A latency probe sits behind a second switch, because it makes a few hundred calls and reports percentiles rather than asserting anything:

TYPESAFE_LATENCY=1 go test -tags integration -run TestLatency -v ./...

It separates the two halves of a call — what the service spent, read from the gateway's X-Envoy-Upstream-Service-Time, and everything else, which is your network and this SDK. Only the first is a property of the API; read the second as a measurement of where you are sitting.

They assert what the SDK must get right — a probability in range, a distribution that sums to one, a label the question offered, the caller's cancellation coming back as theirs — not what the model happens to answer.

Keep a local key out of the repository. .env is gitignored for the purpose:

echo 'export TYPESAFE_API_KEY=sk-...' > .env && chmod 600 .env
. ./.env && go test -tags integration ./...

Design notes

A few choices are worth knowing before you build on them.

Models own their rules. Each question validates its decoded content; request and result types own their invariants, and APIError owns its classification and message. Validation decodes the bytes already encoded for transport, so it does not invoke user marshalers a second time. Types and all their receiver methods live together in the same source file; Client methods are in client.go, while each call owns its attempts in transport.go.

Results carry their HTTP metadata. Every call returns (value, error), and the value has a Meta with the request ID, status, headers, and the raw response body. The typed fields drop anything the SDK does not model; Meta.Body is where to find it.

Cancellation is the context's, and nothing else's. context.Canceled means you gave up. An attempt that ran out of time is a *ConnectionError and matches context.DeadlineExceeded. The two never get confused for each other, which matters when you are deciding whether to try again.

One error type per thing that can go wrong. A response the service refused is an *APIError, matched by class with errors.Is. A request that produced no complete response — a dropped connection or an attempt that timed out — is a *ConnectionError. There is no hierarchy to walk.

Settings are complete values, not patches. A nil *RetryPolicy inherits the level above it and a non-nil one replaces it outright, so no field needs a third state to distinguish "unset" from its zero. Start from DefaultRetryPolicy().

Questions are structs, and the compiler checks them. There are no builder functions: a rubric is a ScoreCriteria slice and a label set is a ChoiceCriteria map, so the shapes that would otherwise need a run-time check cannot be written down wrong.

An unrecognized payload is kept, not dropped. An answer whose type this release does not model arrives as an *UnknownAnswer carrying the original JSON, so one unfamiliar answer does not cost you the answers beside it. The typed accessors still refuse it.

Known answer types must contain their required, non-null fields. Missing values are errors rather than zero probabilities. A null or empty answers object also fails explicitly, with the service request ID included when available.

The same rules apply when decoding a concrete answer with json.Unmarshal, including its type field. A failed decode leaves the previous answer intact. Unknown answers keep only Raw; AnswerType() reads the type from that JSON, so it cannot disagree with the payload that marshaling returns.

Numbers inside structured ScoreAnswer.Legend descriptions and APIError.Body decode as json.Number, preserving large identifiers and values outside float64's range. Use Number.String(), Int64(), or Float64() according to the application's needs. Scores, confidence, and probabilities remain float64.

Documentation

License

MIT. See LICENSE.