# Type Conventions

## Overview

The SDK uses strict typing with `mypy --strict` and runtime-validated data models with `msgspec`. This document defines type hint patterns and validation approaches.

## Type Hints

### Function Signatures

**Always** include type hints for:
- All parameters
- Return types
- Properties

```python
# ✅ Good: Fully typed
def create_index(
    name: str,
    dimension: int,
    metric: str = "cosine"
) -> Index:
    ...

# ❌ Bad: Missing types
def create_index(name, dimension, metric="cosine"):
    ...
```

### Return Type Hints

**Be specific** about return types:

```python
# ✅ Good: Specific types
def list_indexes() -> list[str]:
    ...

def get_index(name: str) -> Index | None:
    ...

async def embed(text: str) -> list[float]:
    ...

# ❌ Bad: Vague types
def list_indexes() -> list:        # list of what?
    ...

def get_index(name: str) -> Any:   # Use specific type
    ...
```

### None Returns

**Use `None` explicitly**:

```python
# ✅ Good: Explicit None
def delete_index(name: str) -> None:
    ...

def get_optional(key: str) -> str | None:
    ...

# ❌ Bad: Implicit None
def delete_index(name: str):       # Missing return type
    ...
```

## Type Aliases

### Creating Type Aliases

**Pattern**: Use for complex types that appear multiple times

```python
# ✅ Good: Named type aliases
from typing import TypeAlias

Vector: TypeAlias = list[float]
Metadata: TypeAlias = dict[str, str | int | float | bool | list[str]]
SparseVector: TypeAlias = dict[str, list[int] | list[float]]

def upsert(
    vectors: list[tuple[str, Vector, Metadata]]
) -> None:
    ...

# ❌ Bad: Repeated complex types
def upsert(
    vectors: list[tuple[str, list[float], dict[str, str | int | float | bool | list[str]]]]
) -> None:  # Too complex, hard to read
    ...
```

## Protocols

### Protocol Definition

**Pattern**: Use `typing.Protocol` with `@runtime_checkable`

```python
from typing import Protocol, runtime_checkable

# ✅ Good: Complete protocol
@runtime_checkable
class ClientProtocol(Protocol):
    """Defines the public Client interface."""

    @property
    def api_key(self) -> str:
        """The API key used for authentication."""
        ...

    def list_indexes(self) -> list[str]:
        """List all index names."""
        ...

    async def list_indexes(self) -> list[str]:
        """List all index names (async version)."""
        ...

# ❌ Bad: Missing runtime_checkable
class ClientProtocol(Protocol):  # Can't use isinstance()
    ...

# ❌ Bad: Missing docstrings
@runtime_checkable
class ClientProtocol(Protocol):
    def list_indexes(self) -> list[str]: ...  # What does this do?
```

### Protocol Usage

**Use protocols for dependency injection**:

```python
# ✅ Good: Accept protocol, not implementation
class IndexManager:
    def __init__(self, http_client: HTTPClientProtocol):
        self._http = http_client

# ❌ Bad: Depend on concrete class
class IndexManager:
    def __init__(self, http_client: HTTPClient):  # Too specific
        self._http = http_client
```

## Data Models with msgspec

> **Note**: Use `@adapter-review` agent to verify response transformations use msgspec correctly.

### Basic Structs

**Pattern**: Use `msgspec.Struct` for data models

```python
from msgspec import Struct

# ✅ Good: msgspec struct with defaults
class IndexConfig(Struct, kw_only=True):
    """Configuration for index creation."""
    name: str
    dimension: int
    metric: str = "cosine"
    replicas: int = 1
    pods: int = 1

# Usage
config = IndexConfig(name="my-index", dimension=1536)

# ❌ Bad: Plain dataclass (slower)
from dataclasses import dataclass

@dataclass
class IndexConfig:
    name: str
    dimension: int
    metric: str = "cosine"
```

### Optional Fields

**Pattern**: Use `| None` for optional fields

```python
# ✅ Good: Explicit optional
class Index(Struct):
    name: str
    dimension: int
    metric: str
    host: str | None = None        # Optional, defaults to None
    replicas: int = 1              # Has default

# ❌ Bad: Using Optional (old style)
from typing import Optional

class Index(Struct):
    name: str
    host: Optional[str] = None     # Use | None instead
```

### Omitting Defaults

**Pattern**: Use `omit_defaults=True` for clean serialization

```python
# ✅ Good: Omit defaults from output
class HTTPConfig(Struct, kw_only=True, omit_defaults=True):
    timeout: int = 30
    max_retries: int = 3
    max_connections: int = 100

config = HTTPConfig()
# Serializes to {} instead of {"timeout": 30, "max_retries": 3, ...}

# ❌ Bad: Always including defaults
class HTTPConfig(Struct, kw_only=True):
    timeout: int = 30
# Always includes all fields in output
```

### Validation

**Principle**: No validation at the model level. Trust API responses.

```python
# ✅ Good: Simple struct, no validation
class Vector(Struct):
    id: str
    values: list[float]
    metadata: dict[str, Any] | None = None

# ❌ Bad: Adding validation logic
class Vector(Struct):
    id: str
    values: list[float]
    metadata: dict[str, Any] | None = None

    def __post_init__(self):
        if not self.id:
            raise ValueError("id cannot be empty")  # Don't do this
        if len(self.values) == 0:
            raise ValueError("values cannot be empty")  # Don't do this
```

**Rationale**:
- Server validates requests
- Client trusts server responses
- Faster performance
- Simpler code

### API Version Information

**Principle**: Models should NOT contain API version information.

```python
# ✅ Good: Clean model without version markers
class IndexModel(Struct, kw_only=True):
    """Index metadata and configuration."""

    name: str
    dimension: int
    metric: str

# ❌ Bad: Adding version information to models
class IndexModel(Struct, kw_only=True):
    """Index metadata and configuration.

    API Version: 2025-10
    """
    __api_version__ = "2025-10"  # Don't do this

    name: str
    dimension: int
    metric: str
```

**Rationale**:
- Models are data structures, not API clients
- API versioning belongs in HTTP request headers (handled by HTTPClient)
- Version information in models creates maintenance burden
- Models should be version-agnostic and reusable
- Keeps model code clean and focused on data shape

**Where version DOES belong**:
```python
# ✅ Good: Version in HTTP request
class IndexNamespace:
    API_VERSION = "2025-10"

    def list(self) -> list[IndexModel]:
        response = self._http.get(
            f"{self._base_url}/indexes",
            headers={"X-Pinecone-Api-Version": self.API_VERSION}
        )
        ...
```

## Generic Types

### Type Variables

**Pattern**: Use `TypeVar` for generic functions

```python
from typing import TypeVar

T = TypeVar("T")
ResponseT = TypeVar("ResponseT")

# ✅ Good: Generic function
def parse_response(response: httpx.Response, model: type[T]) -> T:
    data = orjson.loads(response.content)
    return msgspec.json.decode(data, type=model)

# Usage
index = parse_response(response, Index)

# ❌ Bad: Using Any
def parse_response(response: httpx.Response, model: Any) -> Any:  # Lose type info
    ...
```

### Generic Classes

**Pattern**: Use `Generic[T]` for generic classes

```python
from typing import Generic, TypeVar

T = TypeVar("T")

# ✅ Good: Generic container
class Response(Generic[T], Struct):
    data: T
    status: int

# Usage
response: Response[list[Index]] = Response(data=[...], status=200)

# ❌ Bad: Not generic
class Response(Struct):
    data: Any  # Lose type information
    status: int
```

## Union Types

### Modern Union Syntax

**Pattern**: Use `|` instead of `Union`

```python
# ✅ Good: Modern syntax (Python 3.10+)
def get_index(name: str) -> Index | None:
    ...

def process(value: str | int | float) -> str:
    ...

# ❌ Bad: Old Union syntax
from typing import Union

def get_index(name: str) -> Union[Index, None]:  # Use | None
    ...

def process(value: Union[str, int, float]) -> str:  # Use |
    ...
```

## Async Type Hints

### Async Functions

**Pattern**: Use `async def` with same type hints

```python
# ✅ Good: Async with return type
async def list_indexes() -> list[str]:
    ...

async def get_index(name: str) -> Index | None:
    ...

# ❌ Bad: Missing async keyword or return type
def list_indexes() -> Coroutine[Any, Any, list[str]]:  # Too complex
    ...
```

### Awaitable Types

**Pattern**: Rarely need explicit `Awaitable` type

```python
from typing import Awaitable

# ✅ Good: Return async function directly
async def delayed_call() -> str:
    await asyncio.sleep(1)
    return "done"

# ❌ Bad: Overusing Awaitable type hint
def get_delayed_call() -> Awaitable[str]:  # Usually not needed
    return delayed_call()
```

## Collection Types

### List, Dict, Set

**Pattern**: Use generic versions with element types

```python
# ✅ Good: Specific element types
def list_indexes() -> list[str]:
    ...

def get_metadata() -> dict[str, Any]:
    ...

def get_unique_ids() -> set[str]:
    ...

# ❌ Bad: Untyped collections
def list_indexes() -> list:        # List of what?
    ...

def get_metadata() -> dict:        # Dict with what keys/values?
    ...
```

### Sequences and Iterables

**Pattern**: Use `Sequence` for read-only, `list` for mutability

```python
from typing import Sequence

# ✅ Good: Sequence for read-only input
def batch_upsert(vectors: Sequence[Vector]) -> None:
    ...  # Can accept list, tuple, etc.

# ✅ Good: list when returning new list
def filter_vectors(vectors: Sequence[Vector]) -> list[Vector]:
    return [v for v in vectors if v.values]

# ❌ Bad: Using list for read-only
def batch_upsert(vectors: list[Vector]) -> None:  # Too restrictive
    ...
```

## Type Narrowing

### Type Guards

**Pattern**: Use `isinstance()` for runtime type checking

```python
# ✅ Good: Type narrowing with isinstance
def process_response(response: dict[str, Any] | list[Any]) -> list[str]:
    if isinstance(response, dict):
        # mypy knows response is dict here
        return list(response.keys())
    else:
        # mypy knows response is list here
        return [str(item) for item in response]

# ❌ Bad: No type narrowing
def process_response(response: dict[str, Any] | list[Any]) -> list[str]:
    return list(response.keys())  # Error if response is list
```

### TypeGuard Functions

**Pattern**: Create type guard functions for complex checks

```python
from typing import TypeGuard

def is_error_response(response: dict[str, Any]) -> TypeGuard[dict]:
    return "error" in response and "message" in response

# ✅ Good: Using type guard
response = get_response()
if is_error_response(response):
    # mypy knows response has "error" and "message" keys
    raise APIError(response["message"])
```

## Callable Types

### Function Types

**Pattern**: Use `Callable` for function parameters

```python
from typing import Callable

# ✅ Good: Callable with signature
def retry(
    func: Callable[[str], str],
    arg: str,
    max_attempts: int = 3
) -> str:
    for _ in range(max_attempts):
        try:
            return func(arg)
        except Exception:
            continue
    raise Exception("All retries failed")

# ❌ Bad: No type for callable
def retry(func, arg, max_attempts=3):  # What signature?
    ...
```

## Literal Types

### String Literals

**Pattern**: Use `Literal` for restricted string values

```python
from typing import Literal

MetricType = Literal["cosine", "euclidean", "dotproduct"]

# ✅ Good: Type-safe metric values
def create_index(
    name: str,
    dimension: int,
    metric: MetricType = "cosine"
) -> Index:
    ...

# ❌ Bad: Plain string (any value allowed)
def create_index(
    name: str,
    dimension: int,
    metric: str = "cosine"  # Could pass "invalid"
) -> Index:
    ...
```

### Literal + str for Forward Compatibility

**Pattern**: For API parameters that may evolve, use `Literal | str` to allow new values without SDK updates.

```python
from typing import Literal, TypedDict

# ✅ Good: Forward-compatible with API evolution
InputType = Literal["passage", "query"] | str
TruncateMode = Literal["END", "NONE", "START"] | str

class EmbedParameters(TypedDict, total=False):
    """Parameters for embedding generation.

    All fields optional. Additional parameters supported by the API
    can be passed and will be forwarded.
    """
    input_type: InputType  # Autocompletes known values, accepts new ones
    truncate: TruncateMode

def embed(
    model: str,
    texts: list[str],
    parameters: EmbedParameters | dict[str, Any] | None = None
) -> EmbedResponse:
    ...

# Usage - both work without SDK updates:
embed(model="...", texts=["..."], parameters={"input_type": "passage"})  # Known value
embed(model="...", texts=["..."], parameters={"input_type": "document"})  # New API value
```

**Why this pattern?**

1. **IDE Autocomplete**: Editors suggest `"passage"` and `"query"` when typing
2. **Documentation**: Types show valid values inline without external docs
3. **Forward Compatible**: New API values work immediately, no SDK release needed
4. **No Runtime Validation**: API handles validation, SDK stays flexible
5. **No Breaking Changes**: Adding new `Literal` values is non-breaking

**When to use:**
- API parameters that might gain new values (model parameters, modes, strategies)
- String constants where the API owns the list of valid values
- Any parameter documented as "may support additional values in the future"

### Enum vs Literal: Why Literal Wins for API Parameters

**❌ AVOID Python Enum for API parameters that evolve**

Python's `Enum` creates brittle APIs when the backend can add new values:

```python
from enum import Enum

# ❌ Bad: Enum breaks on new API values
class InputType(Enum):
    PASSAGE = "passage"
    QUERY = "query"

def embed(
    model: str,
    texts: list[str],
    input_type: InputType = InputType.PASSAGE  # Forces enum usage
) -> EmbedResponse:
    ...

# Problem 1: Users can't use new API values until SDK update
embed(model="...", texts=["..."], input_type="document")  # ❌ Type error!

# Problem 2: Poor ergonomics - must use .value
embed(model="...", texts=["..."], input_type=InputType.PASSAGE.value)  # Verbose

# Problem 3: Exhaustive matching becomes a breaking change
def process_input(input_type: InputType) -> None:
    match input_type:  # If API adds "document", this breaks!
        case InputType.PASSAGE:
            ...
        case InputType.QUERY:
            ...
        # No default case = breaks when new enum values added
```

**✅ Good: Literal | str for evolving parameters**

```python
# ✅ Good: Flexible, discoverable, forward-compatible
InputType = Literal["passage", "query"] | str

def embed(
    model: str,
    texts: list[str],
    parameters: dict[str, Any] | None = None  # or TypedDict with InputType
) -> EmbedResponse:
    ...

# All scenarios work:
embed(model="...", texts=["..."], parameters={"input_type": "passage"})  # Known
embed(model="...", texts=["..."], parameters={"input_type": "document"})  # New - works!
```

**Breaking change comparison:**

| Scenario | Plain str | Enum | Literal | Literal + str |
|----------|-----------|------|---------|---------------|
| API adds new value | ✅ Works | 🚨 Breaks users | ⚠️ Type warning | ✅ Works |
| SDK adds new literal | N/A | ✅ Works | ✅ Works | ✅ Works |
| API removes value | 🚨 Runtime error | 🚨 Runtime error | 🚨 Runtime error | 🚨 Runtime error |
| Exhaustive matching | N/A | 🚨 Breaks users | N/A | N/A |

**When Enum IS appropriate:**

Use `Enum` when **your SDK owns** the set of valid values:

```python
from enum import Enum

# ✅ Good: SDK-defined state (won't change based on API)
class LogLevel(Enum):
    DEBUG = "debug"
    INFO = "info"
    WARNING = "warning"
    ERROR = "error"

# ✅ Good: SDK-defined behavior selector
class RetryStrategy(Enum):
    EXPONENTIAL = "exponential"
    LINEAR = "linear"
    NONE = "none"
```

**Summary:**

- **API-owned values** (e.g., model parameters): Use `Literal | str`
- **SDK-owned values** (e.g., SDK behavior flags): Use `Enum`
- **Stable, closed sets** (e.g., HTTP methods): Use pure `Literal`
- **Prefer `TypedDict` + `Literal | str`** for parameter objects to get best IDE experience

## Performance Considerations

### TYPE_CHECKING for Import Performance

**Principle**: Don't import types at runtime if only needed for type hints.

```python
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from pinecone._internal.models import Index
    from pinecone._internal.http.client import HTTPClient

# ✅ Good: TYPE_CHECKING avoids runtime import
class IndexManager:
    def __init__(self, http_client: "HTTPClient"):
        self._http = http_client

    def get(self, name: str) -> "Index":
        ...

# ❌ Bad: Runtime import just for typing
from pinecone._internal.models import Index  # Imported at runtime
from pinecone._internal.http.client import HTTPClient

class IndexManager:
    def __init__(self, http_client: HTTPClient):
        self._http = http_client

    def get(self, name: str) -> Index:
        ...
```

**Rationale**:
- Reduces module import time
- Avoids circular import issues
- Only type checkers see the imports

### String Annotations

**Pattern**: Use string annotations for forward references

```python
# ✅ Good: String annotation for forward reference
class Node:
    def __init__(self, value: int, parent: "Node | None" = None):
        self.value = value
        self.parent = parent

    def get_parent(self) -> "Node | None":
        return self.parent

# ❌ Bad: Direct reference (error before class defined)
class Node:
    def __init__(self, value: int, parent: Node | None = None):  # NameError!
        self.value = value
        self.parent = parent
```

### Future Annotations

**Pattern**: Use `from __future__ import annotations` for cleaner code

```python
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from pinecone._internal.models import Index

# ✅ Good: No quotes needed with __future__ annotations
class IndexManager:
    def __init__(self, http_client: HTTPClient):  # Automatically stringified
        self._http = http_client

    def get(self, name: str) -> Index:  # Automatically stringified
        ...

# ❌ Bad: Without __future__, need quotes everywhere
class IndexManager:
    def __init__(self, http_client: "HTTPClient"):
        self._http = http_client

    def get(self, name: str) -> "Index":
        ...
```

### Avoid Heavy Module Imports

**Principle**: Don't import large modules just for type hints

```python
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import numpy as np  # Heavy module, only import for type checking

# ✅ Good: TYPE_CHECKING guards heavy imports
def process_array(arr: "np.ndarray") -> list[float]:
    import numpy as np  # Import at runtime only when used
    return arr.tolist()

# ❌ Bad: Importing numpy at module level just for types
import numpy as np  # Slows down import even if numpy not used

def process_array(arr: np.ndarray) -> list[float]:
    return arr.tolist()
```

### Protocol-Based Type Hints

**Pattern**: Prefer protocols over concrete types to avoid imports

```python
from typing import Protocol

# ✅ Good: Protocol avoids importing concrete class
class HTTPClientProtocol(Protocol):
    def request(self, method: str, url: str) -> dict: ...

class IndexManager:
    def __init__(self, http_client: HTTPClientProtocol):  # No import needed
        self._http = http_client

# ❌ Bad: Must import concrete class
from pinecone._internal.http.client import HTTPClient  # Runtime import

class IndexManager:
    def __init__(self, http_client: HTTPClient):
        self._http = http_client
```

## Type Checking

### mypy Configuration

**Use strict mode**:

```ini
# mypy.ini
[mypy]
strict = True
warn_unused_ignores = True
warn_return_any = True
disallow_any_generics = True
```

### Type Comments

**Avoid** type comments, use annotations:

```python
# ✅ Good: Type annotations
def process(data: dict[str, Any]) -> list[str]:
    items: list[str] = []
    ...

# ❌ Bad: Type comments (Python 2 style)
def process(data):
    # type: (dict[str, Any]) -> list[str]
    items = []  # type: list[str]
    ...
```

## Anti-Patterns

### ❌ Using Any

```python
# Bad: Losing type information
def process(data: Any) -> Any:
    ...
```

**Solution**: Use specific types or generics.

```python
# Good: Specific or generic
T = TypeVar("T")

def process(data: dict[str, str]) -> list[str]:
    ...

def parse(data: bytes, model: type[T]) -> T:
    ...
```

### ❌ Ignoring Types

```python
# Bad: Silencing type checker
result = get_data()  # type: ignore
```

**Solution**: Fix the type issue.

```python
# Good: Proper typing
result: list[str] = get_data()
```

### ❌ Optional Everything

```python
# Bad: Making everything optional
def create_index(
    name: str | None = None,
    dimension: int | None = None,
    metric: str | None = None
) -> Index | None:
    ...
```

**Solution**: Only make truly optional things optional.

```python
# Good: Required vs optional
def create_index(
    name: str,              # Required
    dimension: int,         # Required
    metric: str = "cosine"  # Optional with default
) -> Index:
    ...
```

## Working with Response Models

All SDK response models inherit from `BaseModel`, which provides attribute access and serialization.

### Two Ways to Access Model Data

**1. Attribute Access (Recommended)**

Use for all field access:

```python
response = pc.inference.embed(model="multilingual-e5-large", texts=["hello"])

# Direct attribute access (type-safe, autocomplete in IDEs)
print(response.model)  # 'multilingual-e5-large'
print(response.usage.total_tokens)  # 42

# Check optional fields with hasattr
if hasattr(response, "cache_hit"):
    print(response.cache_hit)
```

**2. Serialization**

Convert to dict or JSON for dynamic access, logging, or storage:

```python
# Convert to dict for dictionary operations
data = response.to_dict()
print(data['model'])  # Regular dict access

# Dynamic field access
field_name = "model"  # From config/user input
print(data[field_name])

# Check if field exists
if "tags" in data:
    print(data["tags"])

# Safe access with default
tags = data.get("tags", {})

# Convert to JSON string
json_str = response.to_json()
with open("response.json", "w") as f:
    f.write(json_str)
```

### BaseModel API

All response models inherit these methods:

- `.field_name` - Attribute access (recommended for all static field access)
- `.to_dict() -> dict[str, Any]` - Recursively convert to Python dictionary
- `.to_json() -> str` - Serialize to JSON string

### When to Use Each Pattern

| Pattern | Use When | Example |
|---------|----------|---------|
| **Attribute** | Static field access, type safety, IDE autocomplete | `response.model` |
| **Serialization** | Dynamic fields, dict operations, logging, storage | `data = response.to_dict()` |

### Complete Example

```python
from pinecone import Pinecone

pc = Pinecone()
response = pc.inference.embed(
    model="multilingual-e5-large",
    texts=["hello world"]
)

# 1. Attribute access (primary interface)
print(response.model)
print(response.usage.total_tokens)

# 2. Serialization for dynamic access
data = response.to_dict()

# Dynamic field access
field = "model"
print(data[field])

# Check optional fields
if "cache_hit" in data:
    print(data["cache_hit"])
else:
    print("No cache info")

# Safe access with default
tags = data.get("tags", {})

# Logging
import logging
logging.info(f"Embed response: {response.to_json()}")

# Save to file
with open("response.json", "w") as f:
    f.write(response.to_json())

# Integration with pandas
import pandas as pd
df = pd.DataFrame([data])
```

### BaseModel for Custom Models

When creating models, inherit from `BaseModel`:

```python
from pinecone.models._base import BaseModel

class MyModel(BaseModel, kw_only=True):
    name: str
    value: int
    optional: str | None = None

# Both patterns work automatically
model = MyModel(name="test", value=42)
print(model.name)           # Attribute access
data = model.to_dict()      # Serialization
print(data["name"])         # Dict access after to_dict()
```

## References

- See [naming.md](./naming.md) for type alias naming conventions
- See [architecture.md](./architecture.md) for protocol usage patterns
- See [errors.md](./errors.md) for exception type hierarchies
- See [async.md](./async.md) for async type patterns
