Introduction
Details matter. Developer experience matters. Every unnecessary import, every unnecessary check on the docs because the type hints are lacking, every hard-to-understand feature, every hard-coded quirk the user can’t turn off—these matter.
So, some guidelines are obvious:
- Inputs shouldn’t require custom imports
- Annotate types precisely
- Avoid unnecessary complication
- Provide extra behavior optionally
Let’s unpack them and give examples one by one.
1. Inputs shouldn’t require custom imports
So simple, yet so commonly ignored. How often do you see this?
from sdk.deeply.nested.path.to.the.method.module import Parameters
await client.get_user(Parameters(email='...'))
I don’t want to walk your stupid repo at every god-damn call! No, give me a TypedDict instead, for pity’s sake. This way I still get the type safety, and no import required:
# SDK code
class Parameters(TypedDict):
email: str
async def get_user(self, parameters: Parameters):
...
# user code
await client.get_user({ 'email': '...' })
Isn’t that nicer?
2. Annotate types precisely
Nothing frustrates me more than an API “SDK” that looks like this:
async def get_users(**kwargs) -> list:
return await request('GET', '/data/users', params=kwargs)
Thanks for your help! I was worrying integrating with the API would be a mess, but these precise annotations make it much easier!
LOL
But that’s not quite it. Correct type annotations may not tell the full picture either. Consider this:
class User(TypedDict):
name: str
...
full_name: NotRequired[str]
"""Only if return_type='full'"""
async def get_users(return_type: Literal['simple', 'full']) -> list[User]:
...
Almost there, but not quite… We can narrow it further:
class SimpleUser(TypedDict):
name: str
...
class FullUser(SimpleUser):
full_name: str
@overload
async def get_users(return_type: Literal['simple']) -> list[SimpleUser]:
...
@overload
async def get_users(return_type: Literal['full']) -> list[FullUser]:
...
Now we’re talking! Chef kiss.
3. Avoid unnecessary complication
Keep your implementation details to yourself, thank you. Nobody cares about them. Yet how often is one force-fed with this kind of thing?
from sdk.client import Client
from sdk.gateway import ApiGateway
from sdk.transport import HttpClient
client = Client(
api_gateway=ApiGateway('api3.company.com'),
transport_client=HttpClient(use_httpx=True),
)
Why?! Why do I have to go through convulsions just to instantiate your damn client? Can’t you pick an HTTP library for me? Can’t you pick an API endpoint? I don’t want to choose, nor care.
No, give me some solid defaults. I should only go through convulsions if I want something really custom:
from sdk import Client
client = Client.new()
And that’s it! If you want, you can optionally give me more options:
# SDK code
class Client:
@classmethod
def new(
cls, *, api_gateway: str = DEFAULT_GATEWAY,
transport: Literal['httpx', 'requests', 'websockets']
):
return cls(...)
4. Provide extra behavior optionally
You’re writing an API client. Don’t get fancy. I don’t need retry logic, I don’t need an exaggeratedly complicated exception hierarchy. I don’t need the SDK to make me a fucking coffee! Your mission is to make integration easy and type-checkable.
Some examples:
Retries could be great! But make them opt-in:
client = Client.new(retries={ 'max': 5, 'delay': 2 })Paging is nice. But not by default:
users = await client.get_users(page=2) # normal, as exposed by the API async for page in client.get_users_paged(): # okay, as a separate thing for user in page: ...
Closing
And that’s it. It’s not complicated, just some solid basics. The principles are clear: simplicity, precision and ease of use.