Skip to content

Type Safety for GrowthBook Clients - #126

Open
madhuchavva wants to merge 15 commits into
mainfrom
type-safety-hardening
Open

Type Safety for GrowthBook Clients#126
madhuchavva wants to merge 15 commits into
mainfrom
type-safety-hardening

Conversation

@madhuchavva

@madhuchavva madhuchavva commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Make wrong usage of the SDK visible at development time

Summary

The SDK already has mypy checks and broader annotations, but the goal is to build compile-time safety for most-used parts of the public API. Nothing about runtime behavior changes.

How things were vs. how they are now

Feature values. Before, the value was untyped — this passed every check and blew up (or silently misbehaved) at runtime:

# BEFORE: no complaint from any tool; fails at runtime
banner = gb.get_feature_value("banner-text", "hello")
padding = banner + 10          # str + int, nobody noticed

# NOW: flagged immediately
banner = gb.get_feature_value("banner-text", "hello")   # editor knows this is `str`
padding = banner + 10          # error: str + int
size = gb.get_feature_value("font-size", 14)             # editor knows this is `int`

Experiments. Before, result.value was untyped. Now the result type comes from the variations you pass in:

result = gb.run(Experiment(key="cta-test", variations=["Buy now", "Try free"]))
label = result.value.upper()   # editor knows value is `str`, autocompletes .upper()

Callbacks. The sync client calls tracking callback with keyword arguments, so a callback with different parameter names type-checked fine but crashed at runtime. Now the expected names are part of the signature:

# BEFORE: accepted everywhere, crashed at runtime
def on_viewed(exp, res, ctx): ...
gb = GrowthBook(on_experiment_viewed=on_viewed)

# NOW: the line above is an error; this is what's expected
def on_viewed(experiment, result, user_context): ...

Misspelled arguments. Experiment and FeatureRule used to swallow unknown keyword arguments silently (needed for server payloads). They still do at runtime, but a hand-typed typo is now caught:

Experiment(key="t", variations=[1, 2], weigths=[0.5, 0.5])   # error: no such argument
Experiment(**payload_from_api)                                # still fine

Feature keys (opt-in). New generator, equivalent to the JS SDK's GrowthBook<AppFeatures> + CLI type generation. Point it at the features JSON and use typed client that knows feature names and their value types:

python -m growthbook.codegen --input features.json --output growthbook_features.py
from growthbook_features import TypedGrowthBook

gb = TypedGrowthBook(api_host="...", client_key="...")
gb.is_on("dark_mode")                    # ok
gb.is_on("dark_mod")                     # error: unknown feature key
gb.get_feature_value("max_items", "10")  # error: this feature is a number

The generated classes add zero runtime behavior — they inherit everything and only carry type information. Regenerate when your feature list changes (works as a CI step).

Why this matters for editors and coding agents

Both consume the same signal. In VS Code/PyCharm this is red squiggles and correct autocomplete. For coding agents (Claude Code, Cursor, Copilot) it's the checker output they run after generating code — which means an agent that writes gb.get_feature_value("banner", "blue") + 1 now gets an error back and fixes itself, instead of shipping the bug. Before this PR the checkers had nothing to say about any of it. A regression suite (tests/test_typing.py) pins every example above: correct usage must check clean and each wrong usage must produce an error, under both mypy and pyright.

What's NOT changed

  • No runtime validation. No pydantic, no isinstance checks on inputs. The JS SDK makes the same trade: types are advisory and checked while developing, and the SDK keeps its existing lenient runtime behavior. The full test suite (807 tests) passes unchanged.
  • No breaking changes for existing code, with two footnotes. Untyped callers work exactly as before, except: (1) importing internal names from the package root (e.g. from growthbook import PoolManager) no longer works — those were never documented API; (2) the async client now invokes on_experiment_viewed with keyword arguments, matching what the sync client has done since v1.2.0 — an async callback whose parameters aren't named experiment/result/user_context needs a rename. The sync client is unaffected: its keyword call already required these names, which is also why the README's old two-argument example has been silently broken since v1.4.0 added user_context — this PR fixes those examples too.
  • trackingCallback (the legacy constructor argument) now emits a DeprecationWarning pointing at on_experiment_viewed, consistent with the other deprecated aliases.
  • Custom cache / sticky-bucket implementations keep their Dict signatures. We deliberately did not narrow those base classes to stricter dict types: doing so would make existing third-party implementations fail type checking. The JS SDK hit exactly this with its generated types ([Bug] the auto generated types don't work with useFeatureValue growthbook#1729) and had to walk it back; we skipped the mistake.

Out of scope

  • No Runtime input validation
  • Typed user attributes — kept as Dict[str, Any] on purpose, same as the JS SDK's Attributes.

@madhuchavva madhuchavva changed the title Make wrong usage of the SDK visible at development time Type Safety for GrowthBook Clients Aug 6, 2026
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.

1 participant