Designing typed SDKs that feel native in every language
How Loop designs SDKs that feel idiomatic in TypeScript and Python at once — sharing one schema while respecting each language's conventions, types, and error handling.
A good SDK disappears. You reach for it, the call you expected exists, the types catch your mistake before you run the code, and you move on without thinking about the wire format underneath. A bad SDK makes you feel like you are programming in a language that is not quite yours — calling methods named for someone else's conventions, fighting casing, decoding errors that leak HTTP details.
Loop ships SDKs in more than one language, and the hard part is not generating client code. It is making each one feel native while keeping a single source of truth. Here is how we think about that tension.
One schema, many idioms
Every Loop SDK is built from the same API description. A feedback item has a user, a message, a sentiment constrained to positive, neutral, or negative, an optional source and tag, and a status of open or resolved. That schema is the contract.
What changes per language is not the contract but the grammar used to express it. The same feedback.create should read like the language it lives in.
import { Loop } from '@loop/sdk';
const loop = new Loop(process.env.LOOP_API_KEY);
await loop.feedback.create({
user: { id: 'u_8f2c' },
message: 'The onboarding tour is too long.',
sentiment: 'negative',
source: 'in-app-widget',
});
from loop import Loop
import os
loop = Loop(api_key=os.environ['LOOP_API_KEY'])
loop.feedback.create(
user={'id': 'u_8f2c'},
message='The onboarding tour is too long.',
sentiment='negative',
)
Both calls produce the identical request. But the TypeScript version takes an options object and is awaited; the Python version uses keyword arguments and reads synchronously by default. Neither developer should have to know what the other's call looks like.
Respecting each language's conventions
The temptation when generating SDKs is to pick one set of names and impose them everywhere. That is how you end up with camelCase keys in Python or snake_case fields in TypeScript — small frictions that signal the SDK was not really built for you.
We hold a few rules instead:
- Match the local casing. Method and field names follow the host language's norms, even though the wire payload has its own canonical form.
- Use the language's null and optional story. Optional fields are genuinely optional, expressed the way that language expresses absence.
- Return values that feel at home. A created feedback item comes back as a typed object you can navigate with autocomplete, not an untyped bag you have to remember the keys of.
An SDK that fights your language's conventions taxes every single call. The cost is invisible per call and enormous across a codebase.
Types are the documentation that cannot go stale
The most valuable thing a typed SDK gives you is feedback before runtime. If sentiment is a closed set, then writing 'positief' is an error your editor underlines, not a row of bad data you discover in a dashboard next month.
We treat the constrained fields as real types — unions in TypeScript, literals validated in Python — rather than plain strings with a note in the docs. The effect is that the SDK teaches you what is allowed as you type:
sentimentonly acceptspositive,neutral, ornegative.statusisopenorresolved, nothing else.- Required fields like
userandmessagecannot be quietly omitted.
Documentation drifts. Types are checked on every build, which means they are the only documentation that is always correct.
Errors should speak your language too
When something goes wrong, a native-feeling SDK does not hand you a raw status code and a JSON blob. It raises an error your language knows how to catch, with a shape you can branch on. A rate limit is a distinct, recognizable error from an invalid payload, which is distinct again from a network failure.
This matters most around the parts of Loop that are inherently fallible — sending feedback from a flaky client, verifying a signed webhook. The SDK is where we absorb that messiness so your call site stays clean:
import { Loop } from '@loop/sdk';
const loop = new Loop(process.env.LOOP_API_KEY);
try {
await loop.feedback.create({
user: { id: 'u_8f2c' },
message: 'Loving the new dashboard.',
sentiment: 'positive',
source: 'in-app-widget',
});
} catch (err) {
// typed, catchable errors — not an opaque HTTP response
if (err.code === 'rate_limited') {
await retryLater();
} else {
throw err;
}
}
The test: would you notice it is generated?
The bar we hold ourselves to is simple. A developer reading our TypeScript SDK should not be able to tell it shares a generator with the Python one. The shared part — the schema, the guarantees, the behavior of the API — should be invisible. The visible part should look like it was written by someone who only ever writes in their language.
Getting there is mostly restraint. Generate from one source so the SDKs never disagree, then let each one bend fully to its language rather than meeting in some neutral middle that belongs to no one. The native feel is what is left when you remove every place the SDK reminds you it is a translation.