@variantlab/core 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,347 @@
1
+ # API philosophy
2
+
3
+ Why the variantlab public API looks the way it does. This document is about design taste, not feature specs. If you disagree with a choice here, open a discussion — but first, understand the reasoning.
4
+
5
+ ## Table of contents
6
+
7
+ - [Core tenets](#core-tenets)
8
+ - [Hook-first design](#hook-first-design)
9
+ - [Render-prop for component swaps](#render-prop-for-component-swaps)
10
+ - [Why not a single mega-hook](#why-not-a-single-mega-hook)
11
+ - [Why generics over function overloads](#why-generics-over-function-overloads)
12
+ - [Error handling philosophy](#error-handling-philosophy)
13
+ - [Naming conventions](#naming-conventions)
14
+ - [Opinions we deliberately avoid](#opinions-we-deliberately-avoid)
15
+ - [Influences](#influences)
16
+
17
+ ---
18
+
19
+ ## Core tenets
20
+
21
+ 1. **If you can't learn it in 10 minutes, it's wrong.**
22
+ 2. **Type safety is a feature, not a compromise.**
23
+ 3. **One obvious way to do things.**
24
+ 4. **Hooks for values, components for rendering.**
25
+ 5. **Errors are values until they're fatal.**
26
+ 6. **Never surprise the user.**
27
+
28
+ ---
29
+
30
+ ## Hook-first design
31
+
32
+ ### Decision
33
+
34
+ The primary API is hooks, not classes or singletons.
35
+
36
+ ### Why
37
+
38
+ React hooks are the native React idiom. Vue composables, Svelte stores, Solid signals, and Nuxt composables all follow the same pattern. A hook-first API maps 1:1 across frameworks.
39
+
40
+ ```tsx
41
+ // React
42
+ const variant = useVariant("cta-copy");
43
+
44
+ // Vue 3
45
+ const variant = useVariant("cta-copy");
46
+
47
+ // Svelte 5
48
+ const variant = $derived(useVariant("cta-copy"));
49
+
50
+ // Solid
51
+ const variant = useVariant("cta-copy");
52
+ ```
53
+
54
+ Same signature, same mental model, different reactive semantics under the hood.
55
+
56
+ ### What we rejected
57
+
58
+ - **Class-based API** — `new VariantLab().getVariant()`. Too OOP, doesn't fit modern frontend.
59
+ - **Singleton module** — `import vl from 'variantlab'; vl.getVariant()`. Impossible to test, impossible to SSR cleanly.
60
+ - **Provider injection** — `@inject VariantLab`. Angular-style, foreign to React/Vue/Svelte devs.
61
+
62
+ ---
63
+
64
+ ## Render-prop for component swaps
65
+
66
+ ### Decision
67
+
68
+ Component-swap experiments use a render-prop component:
69
+
70
+ ```tsx
71
+ <Variant experimentId="news-card-layout">
72
+ {{
73
+ responsive: <ResponsiveCard />,
74
+ "scale-to-fit": <ScaleToFitCard />,
75
+ "pip-thumbnail": <PipCard />,
76
+ }}
77
+ </Variant>
78
+ ```
79
+
80
+ ### Why
81
+
82
+ The alternative is imperative:
83
+
84
+ ```tsx
85
+ const variant = useVariant("news-card-layout");
86
+ if (variant === "responsive") return <ResponsiveCard />;
87
+ if (variant === "scale-to-fit") return <ScaleToFitCard />;
88
+ if (variant === "pip-thumbnail") return <PipCard />;
89
+ ```
90
+
91
+ Both work. The render-prop version is better because:
92
+
93
+ 1. **Exhaustiveness**: TypeScript can verify that every variant ID has a component
94
+ 2. **Fallback**: `fallback` prop handles unknown variants and errors
95
+ 3. **Consistency**: same pattern across all framework adapters
96
+ 4. **Discoverability**: one grep finds every experiment in the codebase
97
+
98
+ ### Test
99
+
100
+ Can TypeScript catch a missing variant at compile time? Yes, via the codegen'd types:
101
+
102
+ ```tsx
103
+ <Variant experimentId="news-card-layout">
104
+ {{
105
+ responsive: <ResponsiveCard />,
106
+ "scale-to-fit": <ScaleToFitCard />,
107
+ // ❌ Error: Property "pip-thumbnail" is missing in type
108
+ }}
109
+ </Variant>
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Why not a single mega-hook
115
+
116
+ ### Decision
117
+
118
+ We ship multiple narrow hooks instead of one that does everything:
119
+
120
+ ```ts
121
+ useVariant(id) // returns variant ID string
122
+ useVariantValue<T>(id) // returns the value
123
+ useExperiment<T>(id) // returns { variant, value, track }
124
+ useRouteExperiments() // returns experiments for current route
125
+ useSetVariant() // imperative setter (dev only)
126
+ useVariantLabEngine() // low-level engine access
127
+ ```
128
+
129
+ ### Why
130
+
131
+ A single hook like `useVariantLab(id)` returning everything would be:
132
+
133
+ - Harder to type (what does it return?)
134
+ - Harder to tree-shake (pulls in all features even if unused)
135
+ - Confusing (users don't know what they get)
136
+
137
+ Narrow hooks:
138
+
139
+ - Each has one clear purpose
140
+ - Each has one return type
141
+ - Bundlers can tree-shake unused ones
142
+ - IDE autocomplete is meaningful
143
+
144
+ ### The "one obvious way" principle
145
+
146
+ For any given task, there should be one obvious hook to use:
147
+
148
+ | Task | Hook |
149
+ |---|---|
150
+ | Read which variant is active | `useVariant` |
151
+ | Read a variant's value | `useVariantValue` |
152
+ | Track an exposure event | `useExperiment` |
153
+ | Get all experiments on the current page | `useRouteExperiments` |
154
+ | Change a variant from code (dev) | `useSetVariant` |
155
+ | Low-level access | `useVariantLabEngine` |
156
+
157
+ If you find yourself wondering "which hook do I use?", we failed. Open an issue.
158
+
159
+ ---
160
+
161
+ ## Why generics over function overloads
162
+
163
+ ### Decision
164
+
165
+ `useVariantValue<T>(id)` takes a type parameter for the return value, not multiple overloads.
166
+
167
+ ```ts
168
+ // This:
169
+ const price = useVariantValue<number>("pricing");
170
+
171
+ // Not this:
172
+ const price = useVariantValueNumber("pricing");
173
+ ```
174
+
175
+ ### Why
176
+
177
+ - One function name instead of `useVariantValueString`, `useVariantValueNumber`, `useVariantValueBoolean`, etc.
178
+ - Works with codegen: `useVariantValue("pricing")` narrows to `number` automatically when the experiment type is generated.
179
+ - Users can always widen or narrow explicitly.
180
+
181
+ ### When codegen is active
182
+
183
+ With the generated types, the generic is inferred:
184
+
185
+ ```ts
186
+ // Codegen knows cta-copy is a string experiment
187
+ const copy = useVariantValue("cta-copy"); // inferred: "Buy now" | "Get started" | "Try it free"
188
+ ```
189
+
190
+ ### When codegen is not active
191
+
192
+ Users can provide the type manually:
193
+
194
+ ```ts
195
+ const copy = useVariantValue<string>("cta-copy"); // string
196
+ ```
197
+
198
+ ---
199
+
200
+ ## Error handling philosophy
201
+
202
+ ### Decision
203
+
204
+ Errors are values until they're fatal. The engine never throws on expected failures; it returns a default.
205
+
206
+ ### The modes
207
+
208
+ - **fail-open** (default): Any error during resolution returns the experiment's default variant. Logs a warning.
209
+ - **fail-closed**: Any error throws. For apps that prefer loud failures over silent defaults.
210
+
211
+ ### What throws in fail-closed mode
212
+
213
+ - `ConfigValidationError` — config doesn't match schema
214
+ - `SignatureVerificationError` — HMAC fails
215
+ - `UnknownExperimentError` — experiment ID not in config
216
+
217
+ ### What never throws, even in fail-closed mode
218
+
219
+ - Normal variant resolution (falls back to default)
220
+ - Targeting evaluation (if a predicate throws, we catch it and log)
221
+ - Storage read failures (returns undefined)
222
+
223
+ ### Why fail-open by default
224
+
225
+ Because you don't want your app to crash because a feature flag config has a typo. The worst realistic outcome should be "user sees the default variant", never "white screen of death".
226
+
227
+ ### Why fail-closed as an option
228
+
229
+ Because some teams prefer loud failures in tests or staging. It's a one-line config change.
230
+
231
+ ---
232
+
233
+ ## Naming conventions
234
+
235
+ ### Hooks start with `use`
236
+
237
+ Following React/Vue convention.
238
+
239
+ ### Components are PascalCase
240
+
241
+ `<Variant>`, `<VariantLabProvider>`, `<VariantDebugOverlay>`.
242
+
243
+ ### Function names are verbs
244
+
245
+ `createEngine`, `getVariantSSR`, `registerDeepLinkHandler`.
246
+
247
+ ### Type names are nouns
248
+
249
+ `VariantEngine`, `Experiment`, `Variant`, `VariantContext`, `Targeting`.
250
+
251
+ ### Constants are UPPER_SNAKE_CASE, but we have almost none
252
+
253
+ We avoid exporting constants. Users configure via the config, not magic numbers.
254
+
255
+ ### Events are past-tense
256
+
257
+ `assignment`, `exposure`, `variantChanged`, `rollback`, `configLoaded`.
258
+
259
+ ### Options are positional when obvious, named otherwise
260
+
261
+ ```ts
262
+ createEngine(config, options); // 2 positional args, both clear
263
+ setVariant(experimentId, variantId); // 2 positional args, both clear
264
+
265
+ // Everything else uses named args via options objects
266
+ createHttpFetcher({ url, headers, pollInterval });
267
+ ```
268
+
269
+ ---
270
+
271
+ ## Opinions we deliberately avoid
272
+
273
+ ### We don't pick a telemetry provider
274
+
275
+ Users plug their own. We ship an interface, not an adapter for PostHog or Mixpanel. Including one would violate principle 7 (privacy by default).
276
+
277
+ ### We don't pick a state management library
278
+
279
+ No Redux adapter, no Zustand adapter, no Jotai adapter. The engine exposes `subscribe` and that's enough. If users want to integrate with their state library, it's 10 lines of code.
280
+
281
+ ### We don't pick a router
282
+
283
+ No dependency on Expo Router, React Router, Next Router, or anything else. We accept `route: string` as context and you pass whatever router you use.
284
+
285
+ ### We don't pick a storage
286
+
287
+ AsyncStorage, MMKV, SecureStore, IndexedDB, localStorage, sessionStorage, cookies — all valid. We ship adapters for the common ones but the core has zero preferences.
288
+
289
+ ### We don't assume a particular testing library
290
+
291
+ Test utilities work with Jest, Vitest, @testing-library/react, @testing-library/vue, Playwright. No hardcoded assumptions.
292
+
293
+ ### We don't assume a particular build tool
294
+
295
+ Webpack, Rollup, esbuild, swc, tsc, Vite, Parcel — all work. We ship standard ESM+CJS with .d.ts.
296
+
297
+ ---
298
+
299
+ ## Influences
300
+
301
+ Our API taste comes from libraries we admire:
302
+
303
+ - **TanStack Query** — hook-first, TypeScript-first, framework-agnostic core, narrow public surface
304
+ - **Zod** — one obvious way, great type inference, zero magic
305
+ - **tRPC** — end-to-end type safety via codegen
306
+ - **Radix UI** — render-prop components for composition
307
+ - **Jotai** — minimal surface area, composable primitives
308
+ - **Drizzle ORM** — thin wrappers over primitives, no ORM magic
309
+ - **Hono** — small, fast, runs everywhere
310
+ - **Valibot** — zero-dep, tree-shakable, hand-rolled from the start
311
+
312
+ Each of these libraries is opinionated about *design*, not about *implementation*. They all refuse to do more than their one thing, and do it better than libraries that try to do everything.
313
+
314
+ ---
315
+
316
+ ## Anti-influences
317
+
318
+ Libraries whose approach we consciously avoid:
319
+
320
+ - **Redux** (early versions) — too much boilerplate, too many abstractions
321
+ - **Apollo Client** — tries to be everything, ships enormous bundles
322
+ - **Firebase JS SDK** — couples unrelated concerns, hard to tree-shake
323
+ - **Moment.js** — kitchen-sink API, enormous bundle
324
+ - **Lodash** (monolithic) — imports everything or requires per-function imports
325
+
326
+ The lesson from each: scope creep kills libraries. variantlab stays in scope.
327
+
328
+ ---
329
+
330
+ ## How to propose an API change
331
+
332
+ 1. Open a GitHub discussion, not a PR
333
+ 2. State the use case first, the API second
334
+ 3. Show how existing APIs fall short
335
+ 4. Propose 1-3 alternatives
336
+ 5. Include bundle-size impact
337
+ 6. Include how it interacts with the 8 design principles
338
+
339
+ We're willing to change the API. We're not willing to change principles.
340
+
341
+ ---
342
+
343
+ ## See also
344
+
345
+ - [`API.md`](../../API.md) — the canonical API surface
346
+ - [`docs/design/design-principles.md`](./design-principles.md) — the 8 principles
347
+ - [`docs/research/bundle-size-analysis.md`](../research/bundle-size-analysis.md) — why API decisions affect bundle size