# API Design: `types()` Strategy

**Date:** 2026-01-21
**Status:** Partially implemented (`numpy_types()` only)
**Author:** Claude (with developer collaboration)

## Overview

This document proposes an API for the `types()` strategy, which generates
`ak.types.Type` objects for property-based testing of Awkward Array code.

## Background

From the [UX research](./../research/2026-01-21-ux-interview-01.md) and [type system research](./../research/2026-01-21-type-system-research.md):

- The ultimate goal is an `arrays()` strategy with `type` and `form` options
- `types()` is a building block that generates types with fine-grained control
- Target users: Awkward developers, scikit-HEP tool developers, physicists
- Key insight: One Type → Multiple Forms (not deterministic in reverse)

## Design Goals

1. **Comprehensive coverage**: Generate all valid Awkward type structures
2. **Fine-grained control**: Users can constrain which types are generated
3. **Sensible defaults**: Works well out of the box for common use cases
4. **Composability**: Output can be used with future `arrays()` strategy
5. **Consistency**: Follow patterns from existing strategies (`numpy_arrays`,
   `ranges`)

## Proposed API

> **Note:** See
> [positional-keyword-convention](../notes/2026-02-12-positional-keyword-convention.md)
> for the keyword-only convention adopted after this document was written.
> `types()` falls in Group B (all keyword-only): `(*, dtypes=None, ...)`.
> `numpy_types()` is also Group B: `(*, dtypes=None, allow_datetime=True)`.

### Main Strategy: `types()`

```python
def types(
    *,
    # --- Leaf type control ---
    dtypes: st.SearchStrategy[np.dtype] | None = None,
    allow_datetime: bool = True,

    # --- Container type control ---
    allow_list: bool = True,
    allow_regular: bool = True,
    allow_option: bool = True,
    allow_record: bool = True,
    allow_union: bool = True,

    # --- Special types ---
    allow_string: bool = True,
    allow_bytestring: bool = True,
    allow_unknown: bool = False,

    # --- Nesting control ---
    max_depth: int = 3,

    # --- Size limits ---
    max_fields: int = 5,
    max_regular_size: int = 10,
    max_union_variants: int = 3,

    # --- Record options ---
    allow_tuple: bool = True,
) -> st.SearchStrategy[ak.types.Type]:
    '''Strategy for generating Awkward Array types.

    This generates "content types" (types inside ArrayType), not ArrayType
    itself. The result can be wrapped in ArrayType when needed.

    Parameters
    ----------
    dtypes
        Strategy for NumPy dtypes to use in NumpyType leaves. If None, uses
        `supported_dtypes()`.
    allow_datetime
        Include datetime64/timedelta64 in default dtypes. Only effective when
        `dtypes` is None.

    allow_list
        Generate ListType (variable-length lists).
    allow_regular
        Generate RegularType (fixed-length lists).
    allow_option
        Generate OptionType (nullable values).
    allow_record
        Generate RecordType (records and tuples).
    allow_union
        Generate UnionType (tagged unions).

    allow_string
        Generate string types (ListType with string parameters).
    allow_bytestring
        Generate bytestring types (ListType with bytestring parameters).
    allow_unknown
        Generate UnknownType (placeholder type).

    max_depth
        Maximum nesting depth. At depth 0, only leaf types are generated.
    max_fields
        Maximum number of fields in a RecordType.
    max_regular_size
        Maximum size for RegularType.
    max_union_variants
        Maximum number of variants in a UnionType.
    allow_tuple
        Allow anonymous tuples (RecordType with fields=None).

    Returns
    -------
    st.SearchStrategy[ak.types.Type]
        Strategy generating Type objects (not ArrayType).

    Examples
    --------
    >>> import hypothesis_awkward.strategies as st_ak
    >>> t = st_ak.types().example()
    >>> isinstance(t, ak.types.Type)
    True

    >>> # Only integer types, no nesting
    >>> t = st_ak.types(
    ...     dtypes=st_ak.supported_dtypes().filter(lambda d: d.kind == 'i'),
    ...     allow_list=False,
    ...     allow_record=False,
    ...     max_depth=0,
    ... ).example()

    '''
```

### Supporting Strategies

#### `numpy_types()` (implemented)

Generates `NumpyType` leaf types:

```python
def numpy_types(
    dtypes: st.SearchStrategy[np.dtype] | None = None,
    allow_datetime: bool = True,
) -> st.SearchStrategy[ak.types.NumpyType]:
    '''Strategy for NumpyType (primitive/leaf types).

    Parameters
    ----------
    dtypes
        Strategy for NumPy dtypes. If None, uses `supported_dtypes()`.
    allow_datetime
        Include datetime64/timedelta64 when `dtypes` is None.

    Examples
    --------
    >>> t = numpy_types().example()
    >>> isinstance(t, ak.types.NumpyType)
    True
    '''
```

#### `list_types()` (not yet implemented)

Generates `ListType` (variable-length lists):

```python
def list_types(
    content: st.SearchStrategy[ak.types.Type] | None = None,
) -> st.SearchStrategy[ak.types.ListType]:
    '''Strategy for ListType (variable-length lists).

    Parameters
    ----------
    content
        Strategy for content type. If None, uses `numpy_types()`.
    '''
```

#### `regular_types()` (not yet implemented)

Generates `RegularType` (fixed-length lists):

```python
def regular_types(
    content: st.SearchStrategy[ak.types.Type] | None = None,
    max_size: int = 10,
) -> st.SearchStrategy[ak.types.RegularType]:
    '''Strategy for RegularType (fixed-size lists).

    Parameters
    ----------
    content
        Strategy for content type. If None, uses `numpy_types()`.
    max_size
        Maximum size of the regular dimension.
    '''
```

#### `option_types()` (not yet implemented)

Generates `OptionType` (nullable values):

```python
def option_types(
    content: st.SearchStrategy[ak.types.Type] | None = None,
) -> st.SearchStrategy[ak.types.OptionType]:
    '''Strategy for OptionType (nullable values).

    Parameters
    ----------
    content
        Strategy for content type. If None, uses `numpy_types()`.
    '''
```

#### `record_types()` (not yet implemented)

Generates `RecordType` (records and tuples):

```python
def record_types(
    content: st.SearchStrategy[ak.types.Type] | None = None,
    max_fields: int = 5,
    allow_tuple: bool = True,
) -> st.SearchStrategy[ak.types.RecordType]:
    '''Strategy for RecordType (records and tuples).

    Parameters
    ----------
    content
        Strategy for field content types. If None, uses `numpy_types()`.
    max_fields
        Maximum number of fields.
    allow_tuple
        Allow anonymous tuples (fields=None).
    '''
```

#### `union_types()` (not yet implemented)

Generates `UnionType` (tagged unions):

```python
def union_types(
    content: st.SearchStrategy[ak.types.Type] | None = None,
    max_variants: int = 3,
) -> st.SearchStrategy[ak.types.UnionType]:
    '''Strategy for UnionType (tagged unions).

    Parameters
    ----------
    content
        Strategy for variant types. If None, uses `numpy_types()`.
    max_variants
        Maximum number of union variants (minimum is 2).
    '''
```

#### `string_types()` and `bytestring_types()` (not yet implemented)

Convenience strategies for string types:

```python
def string_types() -> st.SearchStrategy[ak.types.ListType]:
    '''Strategy for string type (ListType with string parameters).

    Returns the canonical string type representation:
    ListType(NumpyType('uint8', {'__array__': 'char'}), {'__array__': 'string'})
    '''


def bytestring_types() -> st.SearchStrategy[ak.types.ListType]:
    '''Strategy for bytestring type (ListType with bytestring parameters).

    Returns the canonical bytestring type representation:
    ListType(NumpyType('uint8', {'__array__': 'char'}), {'__array__': 'bytestring'})
    '''
```

## Implementation Strategy

### Recursive Generation

The `types()` strategy uses depth-limited recursion:

```python
@st.composite
def types(draw, max_depth=3, ...):
    if max_depth <= 0:
        # Base case: leaf types only
        return draw(leaf_types(...))

    # Collect allowed type strategies
    strategies = [leaf_types(...)]

    if allow_list:
        strategies.append(list_types(content=types(max_depth=max_depth-1, ...)))

    if allow_regular:
        strategies.append(regular_types(content=types(max_depth=max_depth-1, ...)))

    if allow_option:
        strategies.append(option_types(content=types(max_depth=max_depth-1, ...)))

    if allow_record:
        strategies.append(record_types(content=types(max_depth=max_depth-1, ...)))

    if allow_union:
        strategies.append(union_types(content=types(max_depth=max_depth-1, ...)))

    return draw(st.one_of(*strategies))
```

### Field Name Generation

For RecordType, field names need to be:

- Valid Python identifiers (for attribute access)
- Unique within a record

```python
def field_names(n: int) -> st.SearchStrategy[list[str]]:
    '''Generate n unique field names.'''
    return st.lists(
        st.from_regex(r'[a-z][a-z0-9_]*', fullmatch=True),
        min_size=n,
        max_size=n,
        unique=True,
    )
```

## Design Decisions

### 1. No ArrayType/ScalarType in Main Strategy

**Decision:** `types()` generates content types, not `ArrayType` or `ScalarType`.

**Rationale:**

- `ArrayType` is just a wrapper with a length
- The length is determined when creating actual arrays
- Users can wrap with `ArrayType(content, length=n)` if needed
- Keeps the API simpler

### 2. Default Includes All Type Classes

**Decision:** By default, all type classes are enabled.

**Rationale:**

- Maximizes coverage for finding edge cases
- Users can disable specific types as needed
- Matches typical property-based testing philosophy

### 3. Separate allow_datetime Option

**Decision:** `allow_datetime` is separate from `dtypes` parameter.

**Rationale:**

- datetime64/timedelta64 have many edge cases
- Common to want "all numeric types except datetime"
- More ergonomic than custom dtype strategy

### 4. String Types as Special Case

**Decision:** String/bytestring have dedicated `allow_string` flags rather than
being part of `allow_list`.

**Rationale:**

- Strings are semantically different from generic lists
- Common to want lists but not strings (or vice versa)
- Matches how users think about data types

### 5. max_depth Controls Total Nesting

**Decision:** `max_depth=3` means up to 3 levels of nesting.

**Rationale:**

- `max_depth=0` → leaf types only
- `max_depth=1` → one level of containers
- Intuitive meaning matching common usage

## Alternatives Considered

### Alternative A: Type Class Enum

```python
class TypeClass(Enum):
    NUMPY = auto()
    LIST = auto()
    REGULAR = auto()
    ...

def types(allow: set[TypeClass] = None, ...):
    ...
```

**Rejected because:**

- Less discoverable than boolean parameters
- Harder to type correctly
- Boolean flags match existing `numpy_arrays()` pattern

### Alternative B: Datashape String Input

```python
def types(pattern: str = "var * ?{...}"):
    '''Generate types matching a pattern.'''
```

**Rejected because:**

- Complex to implement pattern matching
- Less flexible than strategy composition
- Can be added later if needed

### Alternative C: Separate Content Strategy Parameter

```python
def types(content: st.SearchStrategy[ak.types.Type] | None = None):
    '''Generate container types wrapping content.'''
```

**Rejected because:**

- Confusing interaction with max_depth
- The current API handles recursion internally
- Supporting strategies already provide this flexibility

## Usage Examples

### Basic Usage

```python
import hypothesis_awkward.strategies as st_ak
from hypothesis import given

@given(t=st_ak.types())
def test_something_with_type(t):
    # t is an ak.types.Type
    ...
```

### Only Numeric Flat Arrays

```python
# Filter supported_dtypes() to only numeric types (exclude datetime/bool)
numeric_dtypes = st_ak.supported_dtypes().filter(lambda d: d.kind in 'iuf')

t = st_ak.types(
    dtypes=numeric_dtypes,
    allow_list=False,
    allow_regular=False,
    allow_option=False,
    allow_record=False,
    allow_union=False,
    allow_string=False,
    allow_bytestring=False,
)
```

### HEP-style Jagged Records

```python
# Typical physics data: var * {pt: float64, eta: float64, ...}
t = st_ak.types(
    dtypes=st.just(np.dtype('float64')),
    allow_regular=False,
    allow_union=False,
    allow_option=False,
    allow_string=False,
    max_depth=2,  # var * {fields}
    max_fields=4,
)
```

### Composing with forms() (Future)

```python
# Generate form from type
@given(t=st_ak.types())
def test_forms_from_type(t):
    form = st_ak.forms(type=t).example()
    assert form.type == t
```

## Testing Plan

Following the patterns in
[testing-patterns.md](./../../.claude/rules/testing-patterns.md):

1. **TypedDict for kwargs**: `TypesKwargs`
2. **Strategy for kwargs**: `types_kwargs()`
3. **Main property test**: Verify all options are respected
4. **Edge case reachability tests**:
   - Can generate each type class
   - Can generate deeply nested types
   - Can generate records vs tuples
   - Can generate empty records
   - Can generate string/bytestring types

## File Structure

Current:

```text
src/hypothesis_awkward/strategies/
├── types/
│   ├── __init__.py      # Re-exports numpy_types
│   └── numpy_.py        # numpy_types()
```

Future (when all strategies are implemented):

```text
src/hypothesis_awkward/strategies/
├── types/
│   ├── __init__.py      # Re-exports
│   ├── types.py         # Main types() strategy
│   ├── numpy_.py        # numpy_types()
│   ├── list_.py         # list_types()
│   ├── regular.py       # regular_types()
│   ├── option.py        # option_types()
│   ├── record.py        # record_types()
│   ├── union.py         # union_types()
│   └── string.py        # string_types(), bytestring_types()
```

## Open Questions

1. **Should we support parameters?** Types can have arbitrary parameters
   (e.g., `__record__` for named records). Should we generate these?

2. **Should we support categorical types?** Types with `__categorical__`
   parameter. Worth a dedicated flag?

3. **Should allow_tuple be in record_types() only?** Or also exposed in
   `types()`? (Current design: exposed in both)

4. **Minimum union variants?** UnionType requires at least 2 variants. Should
   we expose `min_union_variants`?

## Completed

1. ~~Implement `numpy_types()` first (simplest, no recursion)~~ ✓
2. ~~Write tests for `numpy_types()`~~ ✓

## Next Steps

1. Add `list_types()`, `regular_types()`, `option_types()`
2. Add `record_types()` with field name generation
3. Add `union_types()`
4. Add `string_types()`, `bytestring_types()`
5. Implement main `types()` with recursive composition
6. Write comprehensive tests following testing patterns

Note: Development focus shifted to the `constructors/arrays()` strategy (see
[arrays-api.md](./2026-02-04-arrays-api.md)). The types strategies remain
planned as building blocks for future `type`/`form` parameters on `arrays()`.
