Repository: jorenham/optype (language: python)

Issue:
Numpy array-like interfaces
Add interfaces for 

- [x] Array interface v3 [[1]][NP-A3]:
    - `__array_interface__`  (attr)
- [x] Emulating arrays (basic) [[2]][NP-BD]: 
    - `__array__` (method)
    - `__array_ufunc__` (method)
    - `__array_function__` (method)
- [x] Emulating arrays (advanced) [[3]][NP-SC], [[4]][NP-BB]:
    - `__array_wrap__` (method)
    - `__array_finalize__`  (method)
    - `__array_priority__` (attr)

This won't require any (optional) runtime deps

[NP-A3]: https://numpy.org/doc/stable/reference/arrays.interface.html
[NP-BD]: https://numpy.org/doc/stable/user/basics.dispatch.html
[NP-SC]: https://numpy.org/doc/stable/user/basics.subclassing.html
[NP-BB]: https://numpy.org/doc/stable/user/c-info.beyond-basics.html


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a050e63a..7662aaa1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,6 +40,9 @@ jobs:
       - name: basedpyright
         run: poetry run basedpyright
 
+      - name: basedpyright --verifytypes
+        run: poetry run basedpyright --ignoreexternal --verifytypes optype
+
       - name: markdownlint
         uses: DavidAnson/markdownlint-cli2-action@v16
         with:
@@ -57,7 +60,11 @@ jobs:
       fail-fast: false
       matrix:
         os: [ubuntu-latest, windows-latest, macos-latest]
-        python-version: ["3.10", "3.11", "3.12", "3.13-dev"]
+        python-version: ["3.10", "3.11", "3.12", "3.13.0-beta.2"]
+        # building numpy2 on windows + py313 results in broken numpy install
+        exclude:
+          - os: windows-latest
+            python-version: "3.13.0-beta.2"
 
     runs-on: ${{ matrix.os }}
 
@@ -73,7 +80,7 @@ jobs:
           cache: poetry
 
       - name: poetry install
-        run: poetry install --sync --only test,test-github
+        run: poetry install --sync --with test-github
 
       - name: pytest
         run: poetry run pytest
diff --git a/.markdownlint.yaml b/.markdownlint.yaml
index b73226b9..513420bc 100644
--- a/.markdownlint.yaml
+++ b/.markdownlint.yaml
@@ -5,6 +5,8 @@ MD007:
   indent: 4
 MD013:
   line_length: 79
+MD031:
+  list_items: false
 MD033:
   allowed_elements:
     - h1
@@ -18,3 +20,6 @@ MD033:
     - tr
     - th
     - td
+    - dl
+    - dt
+    - dd
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8c1ff9fb..72a89907 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -16,7 +16,6 @@ repos:
       - id: check-added-large-files
       - id: check-ast
       - id: check-case-conflict
-      - id: check-docstring-first
       - id: check-executables-have-shebangs
       - id: check-json
       - id: check-merge-conflict
@@ -54,6 +53,8 @@ repos:
     rev: v2.3.0
     hooks:
       - id: codespell
+        additional_dependencies:
+          - tomli
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
     rev: v0.4.9
diff --git a/README.md b/README.md
index ec959285..8fe69621 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,10 @@
 <h1 align="center">optype</h1>
 
-<p align="center">
-    <i>One protocol, one method.</i>
-</p>
-
 <p align="center">
     Building blocks for precise & flexible type hints.
 </p>
 
 <p align="center">
-    <a href="https://github.com/jorenham/optype/actions?query=workflow%3ACI">
-        <img
-            alt="optype - CI"
-            src="https://github.com/jorenham/optype/workflows/CI/badge.svg"
-        />
-    </a>
     <a href="https://pypi.org/project/optype/">
         <img
             alt="optype - PyPI"
@@ -33,6 +23,20 @@
             src="https://img.shields.io/github/license/jorenham/optype?style=flat"
         />
     </a>
+</p>
+<p align="center">
+    <a href="https://github.com/jorenham/optype/actions?query=workflow%3ACI">
+        <img
+            alt="optype - CI"
+            src="https://github.com/jorenham/optype/workflows/CI/badge.svg"
+        />
+    </a>
+    <a href="https://github.com/pre-commit/pre-commit">
+        <img
+            alt="optype - pre-commit"
+            src="https://img.shields.io/badge/pre--commit-enabled-orange?logo=pre-commit"
+        />
+    </a>
     <a href="https://detachhead.github.io/basedpyright">
         <img
             alt="optype - basedpyright"
@@ -57,7 +61,19 @@ Optype is available as [`optype`][OPTYPE] on PyPI:
 pip install optype
 ```
 
+For optional [NumPy][NUMPY] support, it is recommended to use the
+`numpy` extra.
+This ensures that the installed `numpy` version is compatible with
+`optype`, following [NEP 29][NEP29] and [SPEC 0][SPEC0].
+
+```shell
+pip install "optype[numpy]"
+```
+
+See the [`optype.numpy` docs](#numpy) for more info.
+
 [OPTYPE]: https://pypi.org/project/optype/
+[NUMPY]: https://github.com/numpy/numpy
 
 ## Example
 
@@ -165,40 +181,86 @@ def twice2[R](x: CMul2[R]) -> R:
 
 See [`examples/twice.py`](examples/twice.py) for the full example.
 
-## Overview
+## Reference
 
 The API of `optype` is flat; a single `import optype` is all you need.
-There are four flavors of things that live within `optype`,
 
-- `optype.Can{}` types describe *what can be done* with it.
-  For instance, any `CanAbs[T]` type can be used as argument to the `abs()`
-  builtin function with return type `T`. Most `Can{}` implement a single
-  special method, whose name directly matched that of the type. `CanAbs`
-  implements `__abs__`, `CanAdd` implements `__add__`, etc.
-- `optype.Has{}` is the analogue of `Can{}`, but for special *attributes*.
-  `HasName` has the `__name__: str` attribute, `HasDict` has a `__dict__`, etc.
-- `optype.Does{}` describe the *type of operators*. So `DoesAbs` is the type
-  of the `abs({})` builtin function, and `DoesPos` the type of the `+{}` prefix
-  operator.
-- `optype.do_{}` are the correctly-typed implementations of `Does{}`. For
-  each `do_{}` there is a `Does{}`, and vice-versa. So `do_abs: DoesAbs`
-  is the typed alias of `abs({})`, and `do_pos: DoesPos` is a typed version of
-  `operator.pos`. The `optype.do_` operators are more complete than
-  `operators`, has runtime-accessible type annotations, and uses a fully
-  predictable naming scheme.
+There are four flavors of things that live within `optype`,
 
-## Reference
+-
+    `optype.Can{}` types describe *what can be done* with it.
+    For instance, any `CanAbs[T]` type can be used as argument to the `abs()`
+    builtin function with return type `T`. Most `Can{}` implement a single
+    special method, whose name directly matched that of the type. `CanAbs`
+    implements `__abs__`, `CanAdd` implements `__add__`, etc.
+-
+    `optype.Has{}` is the analogue of `Can{}`, but for special *attributes*.
+    `HasName` has a `__name__` attribute, `HasDict` has a `__dict__`, etc.
+-
+    `optype.Does{}` describe the *type of operators*.
+    So `DoesAbs` is the type of the `abs({})` builtin function,
+    and `DoesPos` the type of the `+{}` prefix operator.
+-
+    `optype.do_{}` are the correctly-typed implementations of `Does{}`. For
+    each `do_{}` there is a `Does{}`, and vice-versa.
+    So `do_abs: DoesAbs` is the typed alias of `abs({})`,
+    and `do_pos: DoesPos` is a typed version of `operator.pos`.
+    The `optype.do_` operators are more complete than `operators`,
+    have runtime-accessible type annotations, and have names you don't
+    need to know by heart.
+
+The reference docs are structured as follows:
+
+<!-- TOC start (generated with https://github.com/derlin/bitdowntoc) -->
+
+- [Core functionality](#core-functionality)
+    - [Builtin type conversion](#builtin-type-conversion)
+    - [Rich relations](#rich-relations)
+    - [Binary operations](#binary-operations)
+    - [Reflected operations](#reflected-operations)
+    - [Inplace operations](#inplace-operations)
+    - [Unary operations](#unary-operations)
+    - [Rounding](#rounding)
+    - [Callables](#callables)
+    - [Iteration](#iteration)
+    - [Awaitables](#awaitables)
+    - [Async Iteration](#async-iteration)
+    - [Containers](#containers)
+    - [Attributes](#attributes)
+    - [Context managers](#context-managers)
+    - [Descriptors](#descriptors)
+    - [Buffer types](#buffer-types)
+- [Standard libs](#standard-libs)
+    - [`copy`](#copy)
+    - [`pickle`](#pickle)
+    - [`dataclasses`](#dataclasses)
+- [NumPy](#numpy)
+    - [Arrays](#arrays)
+        - [`Array`](#array)
+        - [`AnyArray`](#anyarray)
+        - [`CanArray*`](#canarray)
+        - [`HasArray*`](#hasarray)
+    - [Scalars](#scalars)
+        - [`Scalar`](#scalar)
+        - [`Any*Value`](#anyvalue)
+        - [`Any*Type`](#anytype)
+    - [Data type objects](#data-type-objects)
+        - [`DType`](#dtype)
+        - [`HasDType`](#hasdtype)
+        - [`AnyDType`](#anydtype)
+    - [Universal functions](#universal-functions)
+        - [`AnyUFunc`](#anyufunc)
+        - [`CanArrayUFunc`](#canarrayufunc)
+
+<!-- TOC end -->
+
+### Core functionality
 
 All [typing protocols][PC] here live in the root `optype` namespace.
 They are [runtime-checkable][RC] so that you can do e.g.
 `isinstance('snail', optype.CanAdd)`, in case you want to check whether
 `snail` implements `__add__`.
 
-> [!NOTE]
-> It is bad practice to use a [`typing.Protocol`][PC] as base class for your
-> implementation. Because of [`@typing.runtime_checkable`][RC], you can use
-> `isinstance` either way.
-
 Unlike`collections.abc`, `optype`'s protocols aren't abstract base classes,
 i.e. they don't extend `abc.ABC`, only `typing.Protocol`.
 This allows the `optype` protocols to be used as building blocks for `.pyi`
@@ -207,7 +269,7 @@ type stubs.
 [PC]: https://typing.readthedocs.io/en/latest/spec/protocol.html
 [RC]: https://typing.readthedocs.io/en/latest/spec/protocol.html#runtime-checkable-decorator-and-narrowing-types-by-isinstance
 
-### Type conversion
+#### Builtin type conversion
 
 The return type of these special methods is *invariant*. Python will raise an
 error if some other (sub)type is returned.
@@ -253,13 +315,6 @@ This is why these `optype` interfaces don't accept generic type arguments.
         <td><code>__bool__</code></td>
         <td><code>CanBool[R: bool = bool]</code></td>
     </tr>
-    <tr>
-        <td><code>str(_)</code></td>
-        <td><code>do_str</code></td>
-        <td><code>DoesStr</code></td>
-        <td><code>__str__</code></td>
-        <td><code>CanStr[R: str = str]</code></td>
-    </tr>
     <tr>
         <td><code>bytes(_)</code></td>
         <td><code>do_bytes</code></td>
@@ -267,11 +322,19 @@ This is why these `optype` interfaces don't accept generic type arguments.
         <td><code>__bytes__</code></td>
         <td><code>CanBytes[R: bytes = bytes]</code></td>
     </tr>
+    <tr>
+        <td><code>str(_)</code></td>
+        <td><code>do_str</code></td>
+        <td><code>DoesStr</code></td>
+        <td><code>__str__</code></td>
+        <td><code>CanStr[R: str = str]</code></td>
+    </tr>
 </table>
 
 > [!NOTE]
-> The types that can be used within `typing.Literal` accept an optional
-> type parameter `R`. This can be used to indicate a literal return type,
+> The `Can*` interfaces of the types that can used as `typing.Literal`
+> accept an optional type parameter `R`.
+> This can be used to indicate a literal return type,
 > for surgically precise typing, e.g. `None`, `True`, and `42` are
 > instances of `CanBool[Literal[False]]`, `CanInt[Literal[1]]`, and
 > `CanStr[Literal['42']]`, respectively.
@@ -309,10 +372,48 @@ if your type hints are spot-on; `optype` is you friend.
     </tr>
 </table>
 
-### "Rich comparison" operators
+Additionally, `optype` provides protocols for types with (custom) *hash* or
+*index* methods:
+
+<table>
+    <tr>
+        <th colspan="3" align="center">operator</th>
+        <th colspan="2" align="center">operand</th>
+    </tr>
+    <tr>
+        <td>expression</td>
+        <th>function</th>
+        <th>type</th>
+        <td>method</td>
+        <th>type</th>
+    </tr>
+    <tr>
+        <td><code>hash(_)</code></td>
+        <td><code>do_hash</code></td>
+        <td><code>DoesHash</code></td>
+        <td><code>__hash__</code></td>
+        <td><code>CanHash</code></td>
+    </tr>
+    <tr>
+        <td>
+            <code>_.__index__()</code>
+            (<a href="https://docs.python.org/3/reference/datamodel.html#object.__index__">docs</a>)
+        </td>
+        <td><code>do_index</code></td>
+        <td><code>DoesIndex</code></td>
+        <td><code>__index__</code></td>
+        <td><code>CanIndex[R: int = int]</code></td>
+    </tr>
+</table>
+
+#### Rich relations
 
-These special methods generally a `bool`. However, instances of any type can
-be returned.
+The "rich" comparison special methods often return a `bool`.
+However, instances of any type can be returned (e.g. a numpy array).
+This is why the corresponding `optype.Can*` interfaces accept a second type
+argument for the return type, that defaults to `bool` when omitted.
+The first type parameter matches the passed method argument, i.e. the
+right-hand side operand, denoted here as `x`.
 
 <table>
     <tr>
@@ -377,39 +478,13 @@ be returned.
     </tr>
 </table>
 
-### Callable objects
+#### Binary operations
 
-Unlike `operator`, `optype` provides the operator for callable objects:
-`optype.do_call(f, *args. **kwargs)`.
-
-`CanCall` is similar to `collections.abc.Callable`, but is runtime-checkable,
-and doesn't use esoteric hacks.
-
-<table>
-    <tr>
-        <th colspan="3" align="center">operator</th>
-        <th colspan="2" align="center">operand</th>
-    </tr>
-    <tr>
-        <td>expression</td>
-        <th>function</th>
-        <th>type</th>
-        <td>method</td>
-        <th>type</th>
-    </tr>
-    <tr>
-        <td><code>_(*args, **kwargs)</code></td>
-        <td><code>do_call</code></td>
-        <td><code>DoesCall</code></td>
-        <td><code>__call__</code></td>
-        <td><code>CanCall[**Pss, R]</code></td>
-    </tr>
-</table>
-
-### Numeric operations
-
-For describing things that act like numbers. See the [Python docs][NT] for more
-info.
+In the [Python docs][NT], these are referred to as "arithmetic operations".
+But the operands aren't limited to numeric types, and because the
+operations aren't required to be commutative, might be non-deterministic, and
+could have side-effects.
+Classifying them "arithmetic" is, at the very least, a bit of a stretch.
 
 <table>
     <tr>
@@ -539,14 +614,20 @@ info.
     </tr>
 </table>
 
-Note that because `pow()` can take an optional third argument, `optype`
-provides separate interfaces for `pow()` with two and three arguments.
-Additionally, there is the overloaded intersection type
-`CanPow[T, M, R, RM] =: CanPow2[T, R] & CanPow3[T, M, RM]`, as interface
-for types that can take an optional third argument.
+> [!NOTE]
+> Because `pow()` can take an optional third argument, `optype`
+> provides separate interfaces for `pow()` with two and three arguments.
+> Additionally, there is the overloaded intersection type
+> `CanPow[T, M, R, RM] =: CanPow2[T, R] & CanPow3[T, M, RM]`, as interface
+> for types that can take an optional third argument.
+
+#### Reflected operations
 
 For the binary infix operators above, `optype` additionally provides
-interfaces with reflected (swapped) operands:
+interfaces with *reflected* (swapped) operands, e.g. `__radd__` is a reflected
+`__add__`.
+They are named like the original, but prefixed with `CanR` prefix, i.e.
+`__name__.replace('Can', 'CanR')`.
 
 <table>
     <tr>
@@ -663,11 +744,20 @@ interfaces with reflected (swapped) operands:
     </tr>
 </table>
 
-Note that `CanRPow` corresponds to `CanPow2`; the 3-parameter "modulo" `pow`
-does not reflect in Python.
+> [!NOTE]
+> `CanRPow` corresponds to `CanPow2`; the 3-parameter "modulo" `pow` does not
+> reflect in Python.
+>
+> According to the relevant [python docs][RPOW]:
+> > Note that ternary `pow()` will not try calling `__rpow__()` (the coercion
+> > rules would become too complicated).
+
+[RPOW]: https://docs.python.org/3/reference/datamodel.html#object.__rpow__
 
-Similarly, the augmented assignment operators are described by the following
-`optype` interfaces:
+#### Inplace operations
+
+Similar to the reflected ops, the inplace/augmented ops are prefixed with
+`CanI`, namely:
 
 <table>
     <tr>
@@ -679,109 +769,148 @@ Similarly, the augmented assignment operators are described by the following
         <th>function</th>
         <th>type</th>
         <td>method</td>
-        <th>type</th>
+        <th>types</th>
     </tr>
     <tr>
         <td><code>_ += x</code></td>
         <td><code>do_iadd</code></td>
         <td><code>DoesIAdd</code></td>
         <td><code>__iadd__</code></td>
-        <td><code>CanIAdd[T, R]</code></td>
+        <td>
+            <code>CanIAdd[T, R]</code><br>
+            <code>CanIAddSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ -= x</code></td>
         <td><code>do_isub</code></td>
         <td><code>DoesISub</code></td>
         <td><code>__isub__</code></td>
-        <td><code>CanISub[T, R]</code></td>
+        <td>
+            <code>CanISub[T, R]</code><br>
+            <code>CanISubSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ *= x</code></td>
         <td><code>do_imul</code></td>
         <td><code>DoesIMul</code></td>
         <td><code>__imul__</code></td>
-        <td><code>CanIMul[T, R]</code></td>
+        <td>
+            <code>CanIMul[T, R]</code><br>
+            <code>CanIMulSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ @= x</code></td>
         <td><code>do_imatmul</code></td>
         <td><code>DoesIMatmul</code></td>
         <td><code>__imatmul__</code></td>
-        <td><code>CanIMatmul[T, R]</code></td>
+        <td>
+            <code>CanIMatmul[T, R]</code><br>
+            <code>CanIMatmulSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ /= x</code></td>
         <td><code>do_itruediv</code></td>
         <td><code>DoesITruediv</code></td>
         <td><code>__itruediv__</code></td>
-        <td><code>CanITruediv[T, R]</code></td>
+        <td>
+            <code>CanITruediv[T, R]</code><br>
+            <code>CanITruedivSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ //= x</code></td>
         <td><code>do_ifloordiv</code></td>
         <td><code>DoesIFloordiv</code></td>
         <td><code>__ifloordiv__</code></td>
-        <td><code>CanIFloordiv[T, R]</code></td>
+        <td>
+            <code>CanIFloordiv[T, R]</code><br>
+            <code>CanIFloordivSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ %= x</code></td>
         <td><code>do_imod</code></td>
         <td><code>DoesIMod</code></td>
         <td><code>__imod__</code></td>
-        <td><code>CanIMod[T, R]</code></td>
+        <td>
+            <code>CanIMod[T, R]</code><br>
+            <code>CanIModSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ **= x</code></td>
         <td><code>do_ipow</code></td>
         <td><code>DoesIPow</code></td>
         <td><code>__ipow__</code></td>
-        <td><code>CanIPow[T, R]</code></td>
+        <td>
+            <code>CanIPow[T, R]</code><br>
+            <code>CanIPowSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ <<= x</code></td>
         <td><code>do_ilshift</code></td>
         <td><code>DoesILshift</code></td>
         <td><code>__ilshift__</code></td>
-        <td><code>CanILshift[T, R]</code></td>
+        <td>
+            <code>CanILshift[T, R]</code><br>
+            <code>CanILshiftSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ >>= x</code></td>
         <td><code>do_irshift</code></td>
         <td><code>DoesIRshift</code></td>
         <td><code>__irshift__</code></td>
-        <td><code>CanIRshift[T, R]</code></td>
+        <td>
+            <code>CanIRshift[T, R]</code><br>
+            <code>CanIRshiftSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ &= x</code></td>
         <td><code>do_iand</code></td>
         <td><code>DoesIAnd</code></td>
         <td><code>__iand__</code></td>
-        <td><code>CanIAnd[T, R]</code></td>
+        <td>
+            <code>CanIAnd[T, R]</code><br>
+            <code>CanIAndSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ ^= x</code></td>
         <td><code>do_ixor</code></td>
         <td><code>DoesIXor</code></td>
         <td><code>__ixor__</code></td>
-        <td><code>CanIXor[T, R]</code></td>
+        <td>
+            <code>CanIXor[T, R]</code><br>
+            <code>CanIXorSelf[T]</code>
+        </td>
     </tr>
     <tr>
         <td><code>_ |= x</code></td>
         <td><code>do_ior</code></td>
         <td><code>DoesIOr</code></td>
         <td><code>__ior__</code></td>
-        <td><code>CanIOr[T, R]</code></td>
+        <td>
+            <code>CanIOr[T, R]</code><br>
+            <code>CanIOrSelf[T]</code>
+        </td>
     </tr>
 </table>
 
-These augmented operators usually return itself (after some in-place mutation).
+These inplace operators usually return itself (after some in-place mutation).
 But unfortunately, it currently isn't possible to use `Self` for this (i.e.
 something like `type MyAlias[T] = optype.CanIAdd[T, Self]` isn't allowed).
 So to help ease this unbearable pain, `optype` comes equipped with ready-made
 aliases for you to use. They bear the same name, with an additional `*Self`
 suffix, e.g. `optype.CanIAddSelf[T]`.
 
-Additionally, there are the unary arithmetic operators:
+#### Unary operations
 
 <table>
     <tr>
@@ -837,6 +966,8 @@ Additionally, there are the unary arithmetic operators:
     </tr>
 </table>
 
+#### Rounding
+
 The `round()` built-in function takes an optional second argument.
 From a typing perspective, `round()` has two overloads, one with 1 parameter,
 and one with two.
@@ -935,30 +1066,46 @@ But technially speaking, these methods can be made to return anything.
 [MATH]: https://docs.python.org/3/library/math.html
 [NT]: https://docs.python.org/3/reference/datamodel.html#emulating-numeric-types
 
-### Async objects
+#### Callables
 
-The `optype` variant of `collections.abc.Awaitable[R]`. The only difference
-is that `optype.CanAwait[R]` is a pure interface, whereas `Awaitable` is
-also an abstract base class.
+Unlike `operator`, `optype` provides the operator for callable objects:
+`optype.do_call(f, *args. **kwargs)`.
+
+`CanCall` is similar to `collections.abc.Callable`, but is runtime-checkable,
+and doesn't use esoteric hacks.
 
 <table>
     <tr>
-        <th align="center">operator</th>
+        <th colspan="3" align="center">operator</th>
         <th colspan="2" align="center">operand</th>
     </tr>
     <tr>
         <td>expression</td>
+        <th>function</th>
+        <th>type</th>
         <td>method</td>
         <th>type</th>
     </tr>
     <tr>
-        <td><code>await _</code></td>
-        <td><code>__await__</code></td>
-        <td><code>CanAwait[R]</code></td>
+        <td><code>_(*args, **kwargs)</code></td>
+        <td><code>do_call</code></td>
+        <td><code>DoesCall</code></td>
+        <td><code>__call__</code></td>
+        <td><code>CanCall[**Pss, R]</code></td>
     </tr>
 </table>
 
-### Iteration
+> [!NOTE]
+> Pyright (and probably other typecheckers) tend to accept
+> `collections.abc.Callable` in more places than `optype.CanCall`.
+> This could be related to the lack of co/contra-variance specification for
+> `typing.ParamSpec` (they should almost always be contravariant, but
+> currently they can only be invariant).
+>
+> In case you encounter such a situation, please open an issue about it, so we
+> can investigate further.
+
+#### Iteration
 
 The operand `x` of `iter(_)` is within Python known as an *iterable*, which is
 what `collections.abc.Iterable[V]` is often used for (e.g. as base class, or
@@ -999,14 +1146,14 @@ from the abracadabra collections. This is how they are defined:
     </tr>
     <tr>
         <td><code>next(_)</code></td>
-        <td><code>do_next/1</code></td>
+        <td><code>do_next</code></td>
         <td><code>DoesNext</code></td>
         <td><code>__next__</code></td>
         <td><code>CanNext[V]</code></td>
     </tr>
     <tr>
         <td><code>iter(_)</code></td>
-        <td><code>do_iter/1</code></td>
+        <td><code>do_iter</code></td>
         <td><code>DoesIter</code></td>
         <td><code>__iter__</code></td>
         <td><code>CanIter[R: CanNext[Any]]</code></td>
@@ -1019,7 +1166,30 @@ For the sake of compatibility with `collections.abc`, there is
 I.e. it is equivalent to `collections.abc.Iterator[V]`, but without the `abc`
 nonsense.
 
-### Async Iteration
+#### Awaitables
+
+The `optype` is almost the same as `collections.abc.Awaitable[R]`, except
+that `optype.CanAwait[R]` is a pure interface, whereas `Awaitable` is
+also an abstract base class (making it absolutely useless when writing stubs).
+
+<table>
+    <tr>
+        <th align="center">operator</th>
+        <th colspan="2" align="center">operand</th>
+    </tr>
+    <tr>
+        <td>expression</td>
+        <td>method</td>
+        <th>type</th>
+    </tr>
+    <tr>
+        <td><code>await _</code></td>
+        <td><code>__await__</code></td>
+        <td><code>CanAwait[R]</code></td>
+    </tr>
+</table>
+
+#### Async Iteration
 
 Yes, you guessed it right; the abracadabra collections made the exact same
 mistakes for the async iterablors (or was it "iteramblers"...?).
@@ -1067,7 +1237,7 @@ Additionally, there is `optype.CanAIterSelf[R]`, with both the
 
 [AN]: https://github.com/python/typeshed/pull/7491
 
-### Containers
+#### Containers
 
 <table>
     <tr>
@@ -1156,41 +1326,7 @@ Similarly, there is `optype.CanSequence[K: CanIndex | slice, V]`, which is the
 combination of both `CanLen` and `CanItem[I, V]`, and serves as a more
 specific and flexible `collections.abc.Sequence[V]`.
 
-Additionally, `optype` provides protocols for types with (custom) *hash* or
-*index* methods:
-
-<table>
-    <tr>
-        <th colspan="3" align="center">operator</th>
-        <th colspan="2" align="center">operand</th>
-    </tr>
-    <tr>
-        <td>expression</td>
-        <th>function</th>
-        <th>type</th>
-        <td>method</td>
-        <th>type</th>
-    </tr>
-    <tr>
-        <td><code>hash(_)</code></td>
-        <td><code>do_hash</code></td>
-        <td><code>DoesHash</code></td>
-        <td><code>__hash__</code></td>
-        <td><code>CanHash</code></td>
-    </tr>
-    <tr>
-        <td>
-            <code>_.__index__()</code>
-            (<a href="https://docs.python.org/3/reference/datamodel.html#object.__index__">docs</a>)
-        </td>
-        <td><code>do_index</code></td>
-        <td><code>DoesIndex</code></td>
-        <td><code>__index__</code></td>
-        <td><code>CanIndex[R: int = int]</code></td>
-    </tr>
-</table>
-
-### Attribute access
+#### Attributes
 
 <table>
     <tr>
@@ -1243,46 +1379,7 @@ Additionally, `optype` provides protocols for types with (custom) *hash* or
     </tr>
 </table>
 
-### Descriptors
-
-Interfaces for [descriptors](https://docs.python.org/3/howto/descriptor.html).
-
-<table>
-    <tr>
-        <th align="center">operator</th>
-        <th colspan="2" align="center">operand</th>
-    </tr>
-    <tr>
-        <td>expression</td>
-        <td>method</td>
-        <th>type</th>
-    </tr>
-    <tr>
-        <td>
-            <code>v: V = T().d</code><br/>
-            <code>vt: VT = T.d</code>
-        </td>
-        <td><code>__get__</code></td>
-        <td><code>CanGet[T: object, V, VT = V]</code></td>
-    </tr>
-    <tr>
-        <td><code>T().k = v</code></td>
-        <td><code>__set__</code></td>
-        <td><code>CanSet[T: object, V]</code></td>
-    </tr>
-    <tr>
-        <td><code>del T().k</code></td>
-        <td><code>__delete__</code></td>
-        <td><code>CanDelete[T: object]</code></td>
-    </tr>
-    <tr>
-        <td><code>class T: d = _</code></td>
-        <td><code>__set_name__</code></td>
-        <td><code>CanSetName[T: object, N: str = str]</code></td>
-    </tr>
-</table>
-
-### Context managers
+#### Context managers
 
 Support for the `with` statement.
 
@@ -1365,7 +1462,46 @@ For the `async with` statement the interfaces look very similar:
     </tr>
 </table>
 
-### Buffer types
+#### Descriptors
+
+Interfaces for [descriptors](https://docs.python.org/3/howto/descriptor.html).
+
+<table>
+    <tr>
+        <th align="center">operator</th>
+        <th colspan="2" align="center">operand</th>
+    </tr>
+    <tr>
+        <td>expression</td>
+        <td>method</td>
+        <th>type</th>
+    </tr>
+    <tr>
+        <td>
+            <code>v: V = T().d</code><br/>
+            <code>vt: VT = T.d</code>
+        </td>
+        <td><code>__get__</code></td>
+        <td><code>CanGet[T: object, V, VT = V]</code></td>
+    </tr>
+    <tr>
+        <td><code>T().k = v</code></td>
+        <td><code>__set__</code></td>
+        <td><code>CanSet[T: object, V]</code></td>
+    </tr>
+    <tr>
+        <td><code>del T().k</code></td>
+        <td><code>__delete__</code></td>
+        <td><code>CanDelete[T: object]</code></td>
+    </tr>
+    <tr>
+        <td><code>class T: d = _</code></td>
+        <td><code>__set_name__</code></td>
+        <td><code>CanSetName[T: object, N: str = str]</code></td>
+    </tr>
+</table>
+
+#### Buffer types
 
 Interfaces for emulating buffer types using the [buffer protocol][BP].
 
@@ -1393,7 +1529,9 @@ Interfaces for emulating buffer types using the [buffer protocol][BP].
 
 [BP]: https://docs.python.org/3/reference/datamodel.html#python-buffer-protocol
 
-### `copy`
+### Standard libs
+
+#### `copy`
 
 For the [`copy`][CP] standard library, `optype` provides the following
 interfaces:
@@ -1437,7 +1575,7 @@ type CanReplaceSelf[V] = CanReplace[V, CanReplaceSelf[V]]
 
 [CP]: https://docs.python.org/3/library/copy.html
 
-### `pickle`
+#### `pickle`
 
 For the [`pickle`][PK] standard library, `optype` provides the following
 interfaces:
@@ -1494,7 +1632,7 @@ interfaces:
     </tr>
 </table>
 
-### `dataclasses`
+#### `dataclasses`
 
 For the [`dataclasses`][DC] standard library, `optype` provides the
 `HasDataclassFields[V: Mapping[str, Field]]` interface.
@@ -1503,12 +1641,503 @@ dataclass, i.e. `isinstance(obj, optype.HasDataclassFields)`.
 
 [DC]: https://docs.python.org/3/library/dataclasses.html
 
-## Future plans
+### NumPy
+
+Optype supports both NumPy 1 and 2.
+The current minimum supported version is `1.24`,
+following [NEP 29][NEP29] and [SPEC 0][SPEC0].
+
+When using `optype.numpy`, it is recommended to install `optype` with the
+`numpy` extra, ensuring version compatibility:
+
+```shell
+pip install "optype[numpy]"
+```
+
+> [!NOTE]
+> For the remainder of the `optype.numpy` docs, assume that the following
+> import aliases are available.
+>
+> ```python
+> from typing import Any, Literal
+> import numpy as np
+> import numpy.typing as npt
+> import optype.numpy as onp
+> ```
+>
+> For the sake of brevity and readability, the [PEP 695][PEP695] and
+> [PEP 696][PEP696] type parameter syntax will be used, which is supported
+> since Python 3.13.
+
+#### Arrays
+
+##### `Array`
+
+Optype provides the generic `onp.Array` type alias for `np.ndarray`.
+It is similar to `npt.NDArray`, but includes two (optional) type parameters:
+one that matches the *shape type* (`ND: tuple[int, ...]`),
+and one that matches the *scalar type* (`ST: np.generic`).
+It is defined as:
+
+```python
+type Array[
+    ND: tuple[int, ...] = tuple[int, ...],
+    ST: np.generic = Any,
+] = np.ndarray[ND, np.dtype[ST]]
+```
+
+Note that the shape type parameter `ND` matches the type of `np.ndarray.shape`,
+and the scalar type parameter `ST` that of `np.ndarray.dtype.type`.
+
+This way, a vector can be typed as `Array[tuple[int]]`, and a $2 \times 2$
+matrix of integers as `Array[tuple[Literal[2], Literal[2]], np.integer[Any]]`.
+
+##### `AnyArray`
+
+Something that can be used to construct a numpy array is often referred to
+as an *array-like* object, usually annotated with `npt.ArrayLike`.
+But there are two main problems with `npt.ArrayLike`:
+
+1. Its name strongly suggests that it *only* applies to arrays. However,
+  "0-dimensional" are also included, i.e. "scalars" such as `bool`, and
+  `complex`, but also `str`, since numpy considers unicode- and bytestrings
+  to be  "scalars".
+  So `a: npt.ArrayLike = 'array lie'` is a valid statement.
+2. There is no way to narrow the allowed scalar-types, since it's not generic.
+   So instances of `bytes` and arrays of `np.object_` are always included.
+
+`AnyArray[ND, ST, PY]` doesn't have these problems through its (optional)
+generic type parameters:
+
+```python
+type AnyArray[
+    # shape type
+    ND: tuple[int, ...] = tuple[int, ...],
+    # numpy scalar type
+    ST: np.generic = np.generic,
+    # Python builtin scalar type
+    # (note that `complex` includes `bool | int | float`)
+    PT: complex | str | bytes = complex | str | bytes,
+]
+```
+
+> [!NOTE]
+> Unlike `npt.ArrayLike`, `onp.AnyArray` does not include the python scalars
+> (`PT`) directly.
+
+This makes it possible to correctly annotate e.g. a 1-d arrays-like of floats
+as `a: onp.AnyArray[tuple[int], np.floating[Any], float]`.
+
+##### `CanArray*`
+
+<table>
+    <tr>
+<th>type signature</th>
+        <th>method</th>
+        <th>purpose</th>
+    </tr>
+    <tr>
+<td>
+
+```python
+CanArray[
+    ND: tuple[int, ...] = ...,
+    ST: np.generic = ...,
+]
+```
+
+</td>
+        <td>
+            <a href="https://numpy.org/doc/stable/user/basics.interoperability.html#the-array-method">
+                <code>__array__()</code>
+            </a>
+        </td>
+        <td>Turning itself into a numpy array.</td>
+    </tr>
+    <tr>
+<td>
+
+```python
+CanArrayFunction[
+    F: CanCall[..., Any] = ...,
+    R: object = ...,
+]
+```
+
+</td>
+        <td>
+            <a href="https://numpy.org/doc/stable/user/basics.interoperability.html#the-array-function-protocol">
+                <code>__array_function__()</code>
+            </a>
+        </td>
+        <td>
+            Similar to how <code>T.__abs__()</code> implements
+            <code>abs(T)</code>, but for arbitrary numpy callables
+            (that aren't a ufunc).
+        </td>
+    </tr>
+    <tr>
+<td>
+
+```python
+CanArrayFinalize[
+    T: object = ...,
+]
+```
 
-- [numpy][NP] interfaces for arrays-like types (no deps) (#24)
-- [array-api][API-ND] interfaces (no deps) (#25)
-- [dataframe-api][API-DF] interfaces (no deps) (#26)
+</td>
+        <td>
+            <a href="https://numpy.org/doc/stable/user/c-info.beyond-basics.html#the-array-finalize-method">
+                <code>__array_finalize__()</code>
+            </a>
+        </td>
+        <td>
+            Converting the return value of a numpy function back into an
+            instance of the foreign object.
+        </td>
+    </tr>
+    <tr>
+<td>
+
+```python
+CanArrayWrap
+```
+
+</td>
+        <td>
+            <a href="https://numpy.org/doc/stable/user/c-info.beyond-basics.html#ndarray.__array_wrap__">
+                <code>__array_wrap__()</code>
+            </a>
+        </td>
+        <td>
+            Takes a `np.ndarray` instance, and "wraps" it into itself, or some
+            `ndarray` subtype.
+        </td>
+    </tr>
+</table>
+
+##### `HasArray*`
+
+<table>
+    <tr>
+        <th><code>optype.numpy._</code></th>
+        <th>attribute</th>
+        <th>purpose</th>
+    </tr>
+    <tr>
+<td>
+
+```python
+HasArrayInterface[
+    V: Mapping[str, Any] = dict[str, Any],
+]
+```
+
+</td>
+        <td>
+            <a href="https://numpy.org/doc/stable/reference/arrays.interface.html#python-side">
+                <code>__array_interface__</code>
+            </a>
+        </td>
+        <td>
+            The array interface protocol (V3) is used to for efficient
+            data-buffer sharing between array-like objects, and bridges the
+            numpy C-api with the Python side.
+        </td>
+    </tr>
+    <tr>
+<td>
+
+```python
+HasArrayPriority
+```
+
+</td>
+        <td>
+            <a href="https://numpy.org/doc/stable/user/c-info.beyond-basics.html#the-array-priority-attribute">
+                <code>__array_priority__</code>
+            </a>
+        </td>
+        <td>
+            In case an operation involves multiple sub-types, this value
+            determines which one will be used as output type.
+        </td>
+    </tr>
+</table>
+
+#### Scalars
+
+Optype considers the following numpy scalar types:
+
+- *`np.generic`*
+    - `np.bool_` (or `np.bool` with `numpy >= 2`)
+    - `np.object_`
+    - *`np.flexible`*
+        - `np.void`
+        - *`np.character`*
+            - `np.bytes_`
+            - `np.str_`
+    - *`np.number[N: npt.NBitBase]`*
+        - *`np.integer[N: npt.NBitBase]`*
+            - *`np.unsignedinteger[N: npt.NBitBase]`*
+                - `np.ubyte`
+                - `np.ushort`
+                - `np.uintc`
+                - `np.uintp`
+                - `np.ulong`
+                - `np.ulonglong`
+                - `np.uint{8,16,32,64}`
+            - *`np.signedinteger[N: npt.NBitBase]`*
+                - `np.byte`
+                - `np.short`
+                - `np.intc`
+                - `np.intp`
+                - `np.long`
+                - `np.longlong`
+                - `np.int{8,16,32,64}`
+        - *`np.inexact[N: npt.NBitBase]`*
+            - *`np.floating[N: npt.NBitBase]`*
+                - `np.half`
+                - `np.single`
+                - `np.double`
+                - `np.longdouble`
+                - `np.float{16,32,64}`
+            - *`np.complexfloating[N1: npt.NBitBase, N2: npt.NBitBase]`*
+                - `np.csingle`
+                - `np.cdouble`
+                - `np.clongdouble`
+                - `np.complex{64,128}`
+
+See the [docs](https://numpy.org/doc/stable/reference/arrays.scalars.html)
+for more info.
+
+##### `Scalar`
+
+The `optype.numpy.Scalar` interface is a generic runtime-checkable protocol,
+that can be seen as a "more specific" `np.generic`, both in name, and from
+a typing perspective.
+Its signature looks like
+
+```python
+Scalar[
+    # The "Python type", so that `Scalar.item() -> PT`.
+    PT: object,
+    # The "N-bits" type (without having to deal with`npt.NBitBase`).
+    # It matches `SCalar.itemsize: NB`.
+    NB: int = Any,
+]
+```
 
-[NP]: https://github.com/numpy/numpy
-[API-ND]: https://data-apis.org/array-api/latest/
-[API-DF]: https://data-apis.org/dataframe-api/draft/index.html
+It can be used as e.g.
+
+```python
+are_birds_real: Scalar[bool, Literal[1]] = np.bool_(True)
+the_answer: Scalar[int, Literal[2]] = np.uint16(42)
+fine_structure_constant: Scalar[float, Literal[8]] = np.float64(1) / 137
+```
+
+> [!NOTE]
+> The second type argument for `itemsize` can be omitted, which is equivalent
+> to setting it to `Any`.
+
+##### `Any*Value`
+
+For every (standard) numpy scalar type (i.e. subtypes of `np.generic`), there
+is the `optype.numpy.Any{}Value` alias (where `{}` should be replaced with the
+title-cased name of the scalar, without potential trailing underscore).
+
+So for `np.bool_` there's `onp.AnyBoolValue`,
+for `np.uint8` there's `onp.AnyUInt8Value`, and
+for `np.floating[N: npt.NBitBase]` there's `AnyFloating[N: npt.NBitBase]`.
+
+> [!NOTE]
+> The *extended-precision* scalar types (e.g. `np.int128`, `np.float96` and
+> `np.complex512`) are not included, because their availability is
+> platform-dependent.
+
+When a value of type `Any{}Value` is passed to e.g. `np.array`,
+the resulting `np.ndarray` will have a scalar type that matches
+the corresponding `Any{}Value`.
+For instance, passing `x: onp.AnyFloat64Value` as `np.array(x)` returns an
+array of type `onp.Array[tuple[()], np.float64]`
+(where `tuple[()]` implies that its shape is `()`).
+
+Each `Any{}Value` contains at least the relevant `np.generic` subtype,
+zero or more [`ctypes`][CTYPES] types, and
+zero or more of the Python `builtins` types.
+
+So for instance `type AnyUInt8 = np.uint8 | ct.c_uint8`, and
+`type AnyCDouble = np.cdouble | complex`.
+
+[CTYPES]: https://docs.python.org/3/library/ctypes.html
+
+##### `Any*Type`
+
+In the same way as `Any*Value`, there's a `Any*Type` for each of the numpy
+scalar types.
+
+These type aliases describe what's allowed to be passed to e.g. the
+`np.dtype[ST: np.generic]` constructor, so that its scalar type `ST` matches
+the one corresponding to the passed `Any*Type`.
+
+So for example, if some `x: onp.UInt8` is passed to `np.dtype(x)`, then the
+resulting type will be a `np.dtype[np.uint8]`.
+
+This is useful when annotating an (e.g. numpy) function with a `dtype`
+parameter, e.g. `np.arange`.
+Then by using a `@typing.overload` for each of the allowed scalar types,
+it's possible to annotate it in *the most specific way that's possible*,
+whilst keeping the code readable and maintainable.
+
+#### Data type objects
+
+In NumPy, a *dtype* (data type) object, is an instance of the
+`numpy.dtype[ST: np.generic]` type.
+It's commonly used to convey metadata of a scalar type, e.g. within arrays.
+
+##### `DType`
+
+Because the type parameter of `np.dtype` isn't optional, it could be more
+convenient to use the alias `optype.numpy.DType`, which is defined as:
+
+```python
+type DType[ST: np.generic = Any] = np.dtype[ST]
+```
+
+Apart from the "CamelCase" name, the only difference with `np.dtype` is that
+the type parameter can be omitted, in which case it's equivalent to
+`np.dtype[np.generic]`, but shorter.
+
+##### `HasDType`
+
+Many of numpy's public functions accept an (optional) `dtype` argument.
+But here, the term "dtype" has a broader meaning, as it also accepts
+(a subtype of) `np.generic`.
+Additionally, any instance with a `dtype: DType` attribute is accepted.
+The runtime-checkable interface for this is `optype.numpy.HasDType`, which is
+roughly equivalent to the following definition:
+
+```python
+@runtime_checkable
+class HasDType[DT: DType = DType](Protocol):
+    dtype: Final[DT]
+```
+
+Since `np.ndarray` has a `dtype` attribute, it is a subtype of `HasDType`:
+
+```pycon
+>>> isinstance(np.array([42]), onp.HasDType)
+True
+```
+
+##### `AnyDType`
+
+All types that can be passed to the `np.dtype` constructor, as well as the
+types of most `dtype` function parameters, are encapsulated within the
+`optype.numpy.AnyDType` alias, i.e.:
+
+```python
+type AnyDType[ST: np.generic = Any] = type[ST] | DType[ST] | HasDType[DType[ST]]
+```
+
+> [!NOTE]
+> NumPy's own `numpy.typing.DTypeLike` alias serves the same purpose as
+> `AnyDType`.
+> But `npt.DTypeLike` has several issues:
+>
+> - It's not generic (accepts no type parameter(s)), and cannot be narrowed to
+>   allow for specific scalar types. Even though most functions don't accept
+>   *all* possible scalar- and dtypes.
+> - Its definition is maximally broad, e.g. `type[Any]`, and `str` are
+>   included in its union.
+>   So given some arbitrary function parameter `dtype: npt.DTypeLike`, passing
+>   e.g. `dtype="Ceci n'est pas une dtype"` won't look like anything out of the
+>   ordinary for your type checker.
+>
+> These issues aren't the case for `optype.numpy.AnyDType`.
+> However, it (currently) isn't possible to pass scalar char-codes
+> (e.g. `dtype='f8'`) or builtin python types (e.g. `dtype=int`) directly.
+> If you really want to do so anyway, then just pass it to the
+> `np.dtype()` constructor, e.g. `np.arange(42, dtype=np.dtype('f8'))`.
+
+#### Universal functions
+
+A large portion of numpy's public API consists of
+[universal functions][UFUNC-DOC], i.e. (callable) instances of
+[`np.ufunc`][UFUNC-REF].
+
+> [!TIP]
+> Custom ufuncs can be created using [`np.frompyfunc`][FROMPYFUNC], but also
+> through a user-defined class that implements the required attributes and
+> methods (i.e., duck typing).
+
+##### `AnyUFunc`
+
+But `np.ufunc` has a big issue; it accepts no type parameters.
+This makes it very difficult to properly annotate its callable signature and
+its literal attributes (e.g. `.nin` and `.identity`).
+
+This is where `optype.numpy.AnyUFunc` comes into play:
+It's a runtime-checkable generic typing protocol, that has been thoroughly
+type- and unit-tested to ensure compatibility with all of numpy's ufunc
+definitions.
+Its generic type signature looks roughly like:
+
+```python
+AnyUFunc[
+    # The type of the (bound) `__call__` method.
+    Fn: Callable[..., Any] = Any,
+    # The types of the `nin` and `nout` (readonly) attributes.
+    # Within numpy these match either `Literal[1]` or `Literal[2]`.
+    Nin: int = Any,
+    Nout: int = Any,
+    # The type of the `signature` (readonly) attribute;
+    # Must be `None` unless this is a generalized ufunc (gufunc), e.g.
+    # `np.matmul`.
+    Sig: str | None = Any,
+    # The type of the `identity` (readonly) attribute (used in `.reduce`).
+    # Unless `Nin: Literal[2]`, `Nout: Literal[1]`, and `Sig: None`,
+    # this should always be `None`.
+    # Note that `complex` also includes `bool | int | float`.
+    Id: complex | str | bytes | None = Any,
+]
+```
+
+> [!NOTE]
+> Unfortunately, the extra callable methods of `np.ufunc` (`at`, `reduce`,
+> `reduceat`, `accumulate`, and `outer`), are incorrectly annotated (as `None`
+> *attributes*, even though at runtime they're methods that raise a
+> `ValueError` when called).
+> This currently makes it impossible to properly type these in
+> `optype.numpy.AnyUFunc`; doing so would make it incompatible with numpy's
+> ufuncs.
+
+[UFUNC-DOC]: https://numpy.org/doc/stable/reference/ufuncs.html
+[UFUNC-REF]: https://numpy.org/doc/stable/reference/generated/numpy.ufunc.html
+[FROMPYFUNC]: https://numpy.org/doc/stable/reference/generated/numpy.frompyfunc.html
+
+##### `CanArrayUFunc`
+
+When ufuncs are called on some inputs, the ufunc will call the
+`__array_ufunc__`, which is not unlike how `abs()` calls `__abs__`.
+
+With `optype.numpy.CanArrayUFunc`, it becomes straightworward to annotate
+potential arguments to `np.ufunc`.
+It's a single-method runtime-checkable protocol, whose type signature looks
+roughly like:
+
+```python
+CanArrayUFunc[
+    Fn: AnyUFunc = Any,
+]
+```
+
+> [!NOTE]
+> Due to the previously mentioned typing limitations of `np.ufunc`,
+> the `*args` and `**kwargs` of `CanArrayUFunc.__array_ufunc__` are currently
+> impossible to properly annotate.
+
+[NEP29]: https://numpy.org/neps/nep-0029-deprecation_policy.html
+[SPEC0]: https://scientific-python.org/specs/spec-0000/
+[PEP695]: https://peps.python.org/pep-0695/
+[PEP696]: https://peps.python.org/pep-0696/
diff --git a/examples/functor.py b/examples/functor.py
index 02b2d52e..ab0d7cb1 100644
--- a/examples/functor.py
+++ b/examples/functor.py
@@ -19,7 +19,7 @@
 Y = TypeVar('Y')
 
 
-@final  # noqa: PLR0904
+@final
 class Functor(Generic[T_co]):
     __match_args__ = __slots__ = ('value',)
 
diff --git a/optype/numpy/__init__.py b/optype/numpy/__init__.py
new file mode 100644
index 00000000..5c165009
--- /dev/null
+++ b/optype/numpy/__init__.py
@@ -0,0 +1,234 @@
+__all__ = (
+    'AnyArray',
+    'AnyBoolType',
+    'AnyBoolValue',
+    'AnyByteType',
+    'AnyByteValue',
+    'AnyBytesType',
+    'AnyBytesValue',
+    'AnyCDoubleType',
+    'AnyCDoubleValue',
+    'AnyCLongDoubleType',
+    'AnyCLongDoubleValue',
+    'AnyCSingleType',
+    'AnyCSingleValue',
+    'AnyCharacterType',
+    'AnyCharacterValue',
+    'AnyComplex64Type',
+    'AnyComplex64Value',
+    'AnyComplex128Type',
+    'AnyComplex128Value',
+    'AnyComplexFloatingValue',
+    'AnyDType',
+    'AnyDatetime64Type',
+    'AnyDatetime64Value',
+    'AnyDoubleType',
+    'AnyDoubleValue',
+    'AnyFlexibleType',
+    'AnyFlexibleValue',
+    'AnyFloat16Type',
+    'AnyFloat16Value',
+    'AnyFloat32Type',
+    'AnyFloat32Value',
+    'AnyFloat64Type',
+    'AnyFloat64Value',
+    'AnyFloatingType',
+    'AnyFloatingValue',
+    'AnyGenericType',
+    'AnyGenericValue',
+    'AnyHalfType',
+    'AnyHalfValue',
+    'AnyInexactType',
+    'AnyInexactValue',
+    'AnyInt8Type',
+    'AnyInt8Value',
+    'AnyInt16Type',
+    'AnyInt16Value',
+    'AnyInt32Type',
+    'AnyInt32Value',
+    'AnyInt64Type',
+    'AnyInt64Value',
+    'AnyIntCType',
+    'AnyIntCValue',
+    'AnyIntPType',
+    'AnyIntPValue',
+    'AnyIntegerType',
+    'AnyIntegerValue',
+    'AnyLongDoubleType',
+    'AnyLongDoubleValue',
+    'AnyLongLongType',
+    'AnyLongLongValue',
+    'AnyLongType',
+    'AnyLongValue',
+    'AnyNumberType',
+    'AnyNumberValue',
+    'AnyObjectType',
+    'AnyObjectValue',
+    'AnyShortType',
+    'AnyShortValue',
+    'AnySignedIntegerType',
+    'AnySignedIntegerValue',
+    'AnySingleType',
+    'AnySingleValue',
+    'AnyStrType',
+    'AnyStrValue',
+    'AnyTimedelta64Type',
+    'AnyTimedelta64Value',
+    'AnyUByteType',
+    'AnyUByteValue',
+    'AnyUFunc',
+    'AnyUInt8Type',
+    'AnyUInt8Value',
+    'AnyUInt16Type',
+    'AnyUInt16Value',
+    'AnyUInt32Type',
+    'AnyUInt32Value',
+    'AnyUInt64Type',
+    'AnyUInt64Value',
+    'AnyUIntCType',
+    'AnyUIntCValue',
+    'AnyUIntPType',
+    'AnyUIntPValue',
+    'AnyULongLongType',
+    'AnyULongLongValue',
+    'AnyULongType',
+    'AnyULongValue',
+    'AnyUShortType',
+    'AnyUShortValue',
+    'AnyUnsignedIntegerType',
+    'AnyUnsignedIntegerValue',
+    'AnyVoidType',
+    'AnyVoidValue',
+    'Array',
+    'CanArray',
+    'CanArrayFinalize',
+    'CanArrayFunction',
+    'CanArrayUFunc',
+    'CanArrayWrap',
+    'DType',
+    'HasArrayInterface',
+    'HasArrayPriority',
+    'HasDType',
+    'Scalar',
+)
+from ._array import (
+    AnyArray,
+    Array,
+    CanArray,
+    CanArrayFinalize,
+    CanArrayFunction,
+    CanArrayWrap,
+    HasArrayInterface,
+    HasArrayPriority,
+)
+from ._dtype import (
+    AnyDType,
+    DType,
+    HasDType,
+)
+from ._interfaces import (
+    Scalar,
+)
+from ._sctype import (
+    AnyBoolType,
+    AnyBoolValue,
+    AnyByteType,
+    AnyByteValue,
+    AnyBytesType,
+    AnyBytesValue,
+    AnyCDoubleType,
+    AnyCDoubleValue,
+    AnyCLongDoubleType,
+    AnyCLongDoubleValue,
+    AnyCSingleType,
+    AnyCSingleValue,
+    AnyCharacterType,
+    AnyCharacterValue,
+    AnyComplex64Type,
+    AnyComplex64Value,
+    AnyComplex128Type,
+    AnyComplex128Value,
+    AnyComplexFloatingValue,
+    AnyDatetime64Type,
+    AnyDatetime64Value,
+    AnyDoubleType,
+    AnyDoubleValue,
+    AnyFlexibleType,
+    AnyFlexibleValue,
+    AnyFloat16Type,
+    AnyFloat16Value,
+    AnyFloat32Type,
+    AnyFloat32Value,
+    AnyFloat64Type,
+    AnyFloat64Value,
+    AnyFloatingType,
+    AnyFloatingValue,
+    AnyGenericType,
+    AnyGenericValue,
+    AnyHalfType,
+    AnyHalfValue,
+    AnyInexactType,
+    AnyInexactValue,
+    AnyInt8Type,
+    AnyInt8Value,
+    AnyInt16Type,
+    AnyInt16Value,
+    AnyInt32Type,
+    AnyInt32Value,
+    AnyInt64Type,
+    AnyInt64Value,
+    AnyIntCType,
+    AnyIntCValue,
+    AnyIntPType,
+    AnyIntPValue,
+    AnyIntegerType,
+    AnyIntegerValue,
+    AnyLongDoubleType,
+    AnyLongDoubleValue,
+    AnyLongLongType,
+    AnyLongLongValue,
+    AnyLongType,
+    AnyLongValue,
+    AnyNumberType,
+    AnyNumberValue,
+    AnyObjectType,
+    AnyObjectValue,
+    AnyShortType,
+    AnyShortValue,
+    AnySignedIntegerType,
+    AnySignedIntegerValue,
+    AnySingleType,
+    AnySingleValue,
+    AnyStrType,
+    AnyStrValue,
+    AnyTimedelta64Type,
+    AnyTimedelta64Value,
+    AnyUByteType,
+    AnyUByteValue,
+    AnyUInt8Type,
+    AnyUInt8Value,
+    AnyUInt16Type,
+    AnyUInt16Value,
+    AnyUInt32Type,
+    AnyUInt32Value,
+    AnyUInt64Type,
+    AnyUInt64Value,
+    AnyUIntCType,
+    AnyUIntCValue,
+    AnyUIntPType,
+    AnyUIntPValue,
+    AnyULongLongType,
+    AnyULongLongValue,
+    AnyULongType,
+    AnyULongValue,
+    AnyUShortType,
+    AnyUShortValue,
+    AnyUnsignedIntegerType,
+    AnyUnsignedIntegerValue,
+    AnyVoidType,
+    AnyVoidValue,
+)
+from ._ufunc import (
+    AnyUFunc,
+    CanArrayUFunc,
+)
diff --git a/optype/numpy/_array.py b/optype/numpy/_array.py
new file mode 100644
index 00000000..e0ab5a31
--- /dev/null
+++ b/optype/numpy/_array.py
@@ -0,0 +1,225 @@
+"""
+Interfaces and type aliases for NumPy arrays, dtypes, and ufuncs.
+"""
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING, Any, Final, TypeAlias
+
+import numpy as np
+
+
+if sys.version_info >= (3, 13):
+    from typing import (
+        Protocol,
+        Self,
+        TypeVar,
+        overload,
+        runtime_checkable,
+    )
+else:
+    from typing_extensions import (
+        Protocol,
+        Self,  # noqa: TCH002
+        TypeVar,
+        overload,
+        runtime_checkable,
+    )
+
+
+if TYPE_CHECKING:
+    from collections.abc import Callable, Mapping
+    from types import NotImplementedType
+
+    from optype import CanIter, CanIterSelf
+
+
+__all__ = (
+    'AnyArray',
+    'Array',
+    'CanArray',
+    'CanArrayFinalize',
+    'CanArrayFunction',
+    'CanArrayWrap',
+    'HasArrayInterface',
+    'HasArrayPriority',
+)
+
+
+_NP_V2: Final[bool] = (_NP_VERSION := np.__version__).startswith('2.')
+if not _NP_V2:
+    assert _NP_VERSION.startswith('1.'), f'numpy {_NP_VERSION} is unsupported'
+
+
+_AnyShape: TypeAlias = tuple[int, ...]
+
+
+_ND_Array = TypeVar('_ND_Array', bound=_AnyShape, default=_AnyShape)
+_ST_Array = TypeVar('_ST_Array', bound=np.generic, default=np.generic)
+
+Array: TypeAlias = np.ndarray[_ND_Array, np.dtype[_ST_Array]]
+"""NumPy array with optional type params for shape and generic dtype."""
+
+
+_ND_CanArray = TypeVar(
+    '_ND_CanArray',
+    infer_variance=True,
+    bound=_AnyShape,
+    default=_AnyShape,
+)
+_ST_CanArray = TypeVar(
+    '_ST_CanArray',
+    infer_variance=True,
+    bound=np.generic,
+    default=np.generic,
+)
+_DT_CanArray = TypeVar('_DT_CanArray', bound=np.dtype[Any])
+
+
+@runtime_checkable
+class CanArray(Protocol[_ND_CanArray, _ST_CanArray]):
+    @overload
+    def __array__(
+        self,
+        dtype: None = ...,
+        /,
+    ) -> Array[_ND_CanArray, _ST_CanArray]: ...
+    @overload
+    def __array__(
+        self,
+        dtype: _DT_CanArray,
+        /,
+    ) -> np.ndarray[_ND_CanArray, _DT_CanArray]: ...
+
+
+_V__NestedSequence = TypeVar(
+    '_V__NestedSequence',
+    infer_variance=True,
+    bound=object,
+)
+
+
+@runtime_checkable
+class _NestedSequence(Protocol[_V__NestedSequence]):
+    def __len__(self, /) -> int: ...
+    def __getitem__(self, i: int, /) -> (
+        _V__NestedSequence
+        | _NestedSequence[_V__NestedSequence]
+    ): ...
+
+
+_ND__AnyArray = TypeVar(
+    '_ND__AnyArray',
+    bound=_AnyShape,
+    default=_AnyShape,
+)
+_ST__AnyArray = TypeVar(
+    '_ST__AnyArray',
+    bound=np.generic,
+    default=np.generic,
+)
+_PT__AnyArray = TypeVar(
+    '_PT__AnyArray',
+    bound=complex | str | bytes,
+    default=complex | str | bytes,
+)
+
+AnyArray: TypeAlias = (
+    CanArray[_ND__AnyArray, _ST__AnyArray]
+    | _NestedSequence[CanArray[Any, _ST__AnyArray]]
+    | _PT__AnyArray
+    | _NestedSequence[_PT__AnyArray]
+)
+"""
+Generic array-like that can be passed to e.g. `np.array` or `np.asaray`.
+
+    - `Shape: tuple[int, ...]`
+    - `TypeNP: np.generic`
+    - `TypePY: bool | int | float | complex | str | bytes`
+"""
+
+
+_F_CanArrayFunction = TypeVar(
+    '_F_CanArrayFunction',
+    infer_variance=True,
+    bound='Callable[..., Any]',
+    default=Any,
+)
+_R_CanArrayFunction = TypeVar(
+    '_R_CanArrayFunction',
+    infer_variance=True,
+    bound=object,
+    default=object,
+)
+
+
+@runtime_checkable
+class CanArrayFunction(Protocol[_F_CanArrayFunction, _R_CanArrayFunction]):
+    def __array_function__(
+        self,
+        /,
+        func: _F_CanArrayFunction,
+        # although this could be tighter, this ensures numpy.typing compat
+        types: CanIter[CanIterSelf[type[CanArrayFunction[Any, Any]]]],
+        # ParamSpec can only be used on *args and **kwargs for some reason...
+        args: tuple[Any, ...],
+        kwargs: Mapping[str, Any],
+    ) -> NotImplementedType | _R_CanArrayFunction: ...
+
+
+# this is almost always a `ndarray`, but setting a `bound` might break in some
+# edge cases
+_T_CanArrayFinalize = TypeVar(
+    '_T_CanArrayFinalize',
+    infer_variance=True,
+    bound=object,
+    default=object,
+)
+
+
+@runtime_checkable
+class CanArrayFinalize(Protocol[_T_CanArrayFinalize]):
+    def __array_finalize__(self, obj: _T_CanArrayFinalize, /) -> None: ...
+
+
+_ND_CanArrayWrap = TypeVar('_ND_CanArrayWrap')
+_DT_CanArrayWrap = TypeVar('_DT_CanArrayWrap', bound=np.dtype[Any])
+
+
+@runtime_checkable
+class CanArrayWrap(Protocol):
+    if _NP_V2:
+        def __array_wrap__(
+            self,
+            array: np.ndarray[_ND_CanArrayWrap, _DT_CanArrayWrap],
+            context: tuple[np.ufunc, tuple[Any, ...], int] | None = ...,
+            return_scalar: bool = ...,
+            /,
+        ) -> np.ndarray[_ND_CanArrayWrap, _DT_CanArrayWrap] | Self: ...
+    else:
+        def __array_wrap__(
+            self,
+            array: np.ndarray[_ND_CanArrayWrap, _DT_CanArrayWrap],
+            context: tuple[np.ufunc, tuple[Any, ...], int] | None = ...,
+            /,
+        ) -> np.ndarray[_ND_CanArrayWrap, _DT_CanArrayWrap] | Self: ...
+
+
+_V_HasArrayInterface = TypeVar(
+    '_V_HasArrayInterface',
+    infer_variance=True,
+    bound='Mapping[str, Any]',
+    default=dict[str, Any],
+)
+
+
+@runtime_checkable
+class HasArrayInterface(Protocol[_V_HasArrayInterface]):
+    @property
+    def __array_interface__(self, /) -> _V_HasArrayInterface: ...
+
+
+@runtime_checkable
+class HasArrayPriority(Protocol):
+    @property
+    def __array_priority__(self, /) -> float: ...
diff --git a/optype/numpy/_dtype.py b/optype/numpy/_dtype.py
new file mode 100644
index 00000000..38fbeacc
--- /dev/null
+++ b/optype/numpy/_dtype.py
@@ -0,0 +1,55 @@
+import sys
+from typing import Any, TypeAlias
+
+import numpy as np
+
+
+if sys.version_info >= (3, 13):
+    from typing import Protocol, TypeVar, runtime_checkable
+else:
+    from typing_extensions import Protocol, TypeVar, runtime_checkable
+
+
+__all__ = 'AnyDType', 'DType', 'HasDType'
+
+
+_ST_DType = TypeVar('_ST_DType', bound=np.generic, default=np.generic)
+DType: TypeAlias = np.dtype[_ST_DType]
+"""Alias for `numpy.dtype[T: numpy.generic = Any]`."""
+
+
+_DT_HasDType = TypeVar(
+    '_DT_HasDType',
+    infer_variance=True,
+    bound=np.dtype[Any],
+    default=np.dtype[Any],
+)
+
+
+@runtime_checkable
+class HasDType(Protocol[_DT_HasDType]):
+    """
+    `HasDType[DT: np.dtype[Any] = Any]`
+
+    Runtime checkable protocol for objects (or types) that have a `dtype`
+    attribute (or property), such as `numpy.ndarray` instances, or
+    `numpy.generic` "scalar" instances.
+
+    Anything that implements this interface can be used with the `numpy.dtype`
+    constructor, i.e. its constructor is compatible with a signature that
+    looks something like `(HasDType[DT: numpy.DType], ...) -> DT`.
+    """
+    @property
+    def dtype(self, /) -> _DT_HasDType: ...
+
+
+_ST_AnyDType = TypeVar('_ST_AnyDType', bound=np.generic, default=np.generic)
+AnyDType: TypeAlias = (
+    np.dtype[_ST_AnyDType]
+    | type[_ST_AnyDType]
+    | HasDType[np.dtype[_ST_AnyDType]]
+)
+"""
+Subset of `npt.DTypeLike`, with optional type parameter, bound to `np.generic`.
+Useful for overloading a `dtype` parameter.
+"""
diff --git a/optype/numpy/_interfaces.py b/optype/numpy/_interfaces.py
new file mode 100644
index 00000000..94d5ff3d
--- /dev/null
+++ b/optype/numpy/_interfaces.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING, Any, Literal, TypeAlias
+
+import numpy as np
+
+
+if sys.version_info >= (3, 13):
+    from types import CapsuleType
+    from typing import (
+        Protocol,
+        Self,
+        TypeVar,
+        overload,
+        override,
+        runtime_checkable,
+    )
+else:
+    from typing_extensions import (
+        CapsuleType,  # noqa: TCH002
+        Protocol,
+        Self,  # noqa: TCH002
+        TypeVar,
+        overload,
+        override,
+        runtime_checkable,
+    )
+
+
+if TYPE_CHECKING:
+    from numpy._core.multiarray import flagsobj
+
+
+_L0: TypeAlias = Literal[0]
+_L1: TypeAlias = Literal[1]
+
+_DT_Array0D = TypeVar('_DT_Array0D', bound=np.dtype[Any])
+_Array0D: TypeAlias = np.ndarray[tuple[()], _DT_Array0D]
+
+
+# Scalar
+
+_PT_Scalar = TypeVar('_PT_Scalar', infer_variance=True, bound=object)
+_NB_Scalar = TypeVar('_NB_Scalar', infer_variance=True, bound=int, default=Any)
+_DT_Scalar = TypeVar('_DT_Scalar', bound=np.dtype[Any])
+
+
+@runtime_checkable
+class Scalar(Protocol[_PT_Scalar, _NB_Scalar]):
+    """
+    A lightweight `numpy.generic` interface that's actually generic, and
+    doesn't require all that nasty `numpy.typing.NBitBase` stuff.
+    """
+
+    def item(self, k: _L0 | tuple[()] | tuple[_L0] = ..., /) -> _PT_Scalar: ...
+    # unfortunately `| int` is required for compat with `numpy.__init__.pyi`
+    @property
+    def itemsize(self, /) -> _NB_Scalar | int: ...
+
+    @property
+    def base(self, /) -> None: ...
+    @property
+    def data(self, /) -> memoryview: ...
+    @property
+    def dtype(self, /) -> np.dtype[Self]: ...  # pyright: ignore[reportInvalidTypeArguments]
+    @property
+    def flags(self, /) -> flagsobj: ...
+    @property
+    def nbytes(self, /) -> int: ...
+    @property
+    def ndim(self, /) -> _L0: ...
+    @property
+    def shape(self, /) -> tuple[()]: ...
+    @property
+    def size(self, /) -> _L1: ...
+    @property
+    def strides(self, /) -> tuple[()]: ...
+
+    @property
+    def __array_priority__(self, /) -> float: ...  # -1000000.0
+    @property
+    def __array_interface__(self, /) -> dict[str, Any]: ...  # TypedDict?
+    @property
+    def __array_struct__(self, /) -> CapsuleType: ...
+
+    @override
+    def __hash__(self, /) -> int: ...
+    @override
+    def __eq__(self, other: object, /) -> np.bool_: ...  # pyright: ignore[reportIncompatibleMethodOverride]
+    @override
+    def __ne__(self, other: object, /) -> np.bool_: ...  # pyright: ignore[reportIncompatibleMethodOverride]
+
+    def __bool__(self, /) -> bool: ...
+    # Unlike `numpy/__init__.pyi` suggests, there exists no `__bytes__` method
+    # in `np.generic`. Instead, it implements the (C) buffer protocol.
+
+    if sys.version_info >= (3, 12):
+        def __buffer__(self, flags: int, /) -> memoryview: ...
+
+    def __copy__(self, /) -> Self: ...
+    def __deepcopy__(self, memo: dict[int, Any] | None, /) -> Self: ...
+
+    @overload
+    def __array__(self, /) -> _Array0D[np.dtype[Self]]: ...  # pyright: ignore[reportInvalidTypeArguments]
+    @overload
+    def __array__(self, dtype: _DT_Scalar, /) -> _Array0D[_DT_Scalar]: ...
diff --git a/optype/numpy/_sctype.py b/optype/numpy/_sctype.py
new file mode 100644
index 00000000..c3e5e112
--- /dev/null
+++ b/optype/numpy/_sctype.py
@@ -0,0 +1,743 @@
+from __future__ import annotations
+
+import ctypes as ct
+import sys
+from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias
+
+import numpy as np
+
+from ._dtype import AnyDType as _SoloType
+
+
+if sys.version_info >= (3, 13):
+    from typing import TypeVar
+else:
+    from typing_extensions import TypeVar
+
+
+if TYPE_CHECKING:
+    import numpy.typing as npt
+
+
+_NP_V2: Final[bool] = np.__version__.startswith('2.')
+
+
+_ST__DualType = TypeVar('_ST__DualType', bound=np.generic)
+_PT__DualType = TypeVar('_PT__DualType', bound=object)
+_DualType: TypeAlias = _SoloType[_ST__DualType] | type[_PT__DualType]
+
+#
+# unsigned integers
+#
+
+# uint8
+_AnyUInt8NP: TypeAlias = np.uint8
+_AnyUInt8Name: TypeAlias = Literal['uint8']
+_AnyUInt8Char: TypeAlias = Literal['u1', '=u1', '<u1', '>u1']
+_AnyUInt8Code: TypeAlias = _AnyUInt8Name | _AnyUInt8Char
+AnyUInt8Value: TypeAlias = _AnyUInt8NP | ct.c_uint8
+AnyUInt8Type: TypeAlias = _AnyUInt8Code | _DualType[_AnyUInt8NP, AnyUInt8Value]
+
+# uint16
+_AnyUInt16NP: TypeAlias = np.uint16
+_AnyUInt16Name: TypeAlias = Literal['uint16']
+_AnyUInt16Char: TypeAlias = Literal['u2', '=u2', '<u2', '>u2']
+_AnyUInt16Code: TypeAlias = _AnyUInt16Name | _AnyUInt16Char
+AnyUInt16Value: TypeAlias = _AnyUInt16NP | ct.c_uint16
+AnyUInt16Type: TypeAlias = (
+    _AnyUInt16Code
+    | _DualType[_AnyUInt16NP, AnyUInt16Value]
+)
+
+# uint32
+_AnyUInt32NP: TypeAlias = np.uint32
+_AnyUInt32Name: TypeAlias = Literal['uint32']
+_AnyUInt32Char: TypeAlias = Literal['u4', '=u4', '<u4', '>u4']
+_AnyUInt32Code: TypeAlias = _AnyUInt32Name | _AnyUInt32Char
+AnyUInt32Value: TypeAlias = _AnyUInt32NP | ct.c_uint32
+AnyUInt32Type: TypeAlias = (
+    _AnyUInt32Code
+    | _DualType[_AnyUInt32NP, AnyUInt32Value]
+)
+
+# uint64
+_AnyUInt64: TypeAlias = np.uint64
+_AnyUInt64Name: TypeAlias = Literal['uint64']
+_AnyUInt64Char: TypeAlias = Literal['u8', '=u8', '<u8', '>u8']
+_AnyUInt64Code: TypeAlias = _AnyUInt64Name | _AnyUInt64Char
+AnyUInt64Value: TypeAlias = _AnyUInt64 | ct.c_uint64
+AnyUInt64Type: TypeAlias = (
+    _AnyUInt64Code
+    | _DualType[_AnyUInt64, AnyUInt64Value]
+)
+
+# ubyte
+_AnyUByteNP: TypeAlias = np.ubyte
+_AnyUByteName: TypeAlias = Literal['ubyte']
+_AnyUByteChar: TypeAlias = Literal['B', '=B', '<B', '>B']
+_AnyUByteCode: TypeAlias = _AnyUByteName | _AnyUByteChar
+AnyUByteValue: TypeAlias = _AnyUByteNP | ct.c_ubyte
+AnyUByteType: TypeAlias = _AnyUByteCode | _DualType[_AnyUByteNP, AnyUByteValue]
+
+# ushort
+_AnyUShortNP: TypeAlias = np.ushort
+_AnyUShortName: TypeAlias = Literal['ushort']
+_AnyUShortChar: TypeAlias = Literal['H', '=H', '<H', '>H']
+_AnyUShortCode: TypeAlias = _AnyUShortName | _AnyUShortChar
+AnyUShortValue: TypeAlias = _AnyUShortNP | ct.c_ushort
+AnyUShortType: TypeAlias = (
+    _AnyUShortCode
+    | _DualType[_AnyUShortNP, AnyUShortValue]
+)
+
+# uintc
+_AnyUIntCNP: TypeAlias = np.uintc
+_AnyUIntCName: TypeAlias = Literal['uintc']
+_AnyUIntCChar: TypeAlias = Literal['I', '=I', '<I', '>I']
+_AnyUIntCCode: TypeAlias = _AnyUIntCName | _AnyUIntCChar
+AnyUIntCValue: TypeAlias = _AnyUIntCNP | ct.c_uint
+AnyUIntCType: TypeAlias = _AnyUIntCCode | _DualType[_AnyUIntCNP, AnyUIntCValue]
+
+# uintp (assuming `uint_ptr_t == size_t` like in `numpy.typing`)
+_AnyUIntPNP: TypeAlias = np.uintp
+if _NP_V2:
+    _AnyUIntPName: TypeAlias = Literal['uintp', 'uint']
+    _AnyUIntPChar: TypeAlias = Literal['N', '=N', '<N', '>N']
+else:
+    _AnyUIntPName: TypeAlias = Literal['uintp', 'uint0']
+    _AnyUIntPChar: TypeAlias = Literal['P', '=P', '<P', '>P']
+_AnyUIntPCode: TypeAlias = _AnyUIntPName | _AnyUIntPChar
+AnyUIntPValue: TypeAlias = _AnyUIntPNP | ct.c_void_p | ct.c_size_t
+AnyUIntPType: TypeAlias = _AnyUIntPCode | _DualType[_AnyUIntPNP, AnyUIntPValue]
+
+# ulong (uint on numpy^=1)
+if _NP_V2:
+    _AnyULongNP: TypeAlias = np.ulong
+    _AnyULongName: TypeAlias = Literal['ulong']
+else:
+    _AnyULongNP: TypeAlias = np.uint
+    _AnyULongName: TypeAlias = Literal['ulong', 'uint']
+_AnyULongChar: TypeAlias = Literal['L', '=L', '<L', '>L']
+_AnyULongCode: TypeAlias = _AnyULongName | _AnyULongChar
+AnyULongValue: TypeAlias = _AnyULongNP | ct.c_ulong
+AnyULongType: TypeAlias = _AnyULongCode | _DualType[_AnyULongNP, AnyULongValue]
+
+# ulonglong
+_AnyULongLongNP: TypeAlias = np.ulonglong
+_AnyULongLongName: TypeAlias = Literal['ulonglong']
+_AnyULongLongChar: TypeAlias = Literal['Q', '=Q', '<Q', '>Q']
+_AnyULongLongCode: TypeAlias = _AnyULongLongName | _AnyULongLongChar
+AnyULongLongValue: TypeAlias = _AnyULongLongNP | ct.c_ulonglong
+AnyULongLongType: TypeAlias = (
+    _AnyULongLongCode
+    | _DualType[_AnyULongLongNP, AnyULongLongValue]
+)
+
+# unsignedinteger
+_NB_unsignedinteger = TypeVar(
+    '_NB_unsignedinteger',
+    bound='npt.NBitBase',
+    default=Any,
+)
+_AnyUnsignedIntegerNP: TypeAlias = np.unsignedinteger[_NB_unsignedinteger]
+# fmt: off
+_AnyUnsignedIntegerCT: TypeAlias = (
+    ct.c_uint8 | ct.c_uint16 | ct.c_uint32 | ct.c_uint64
+    | ct.c_ubyte | ct.c_ushort | ct.c_uint | ct.c_ulong | ct.c_ulonglong
+    | ct.c_size_t | ct.c_void_p
+)
+# fmt: on
+# `builtins.int` is signed
+_AnyUnsignedIntegerCode: TypeAlias = (
+    _AnyUInt8Code | _AnyUInt16Code | _AnyUInt32Code | _AnyUInt64Code
+    | _AnyUByteCode | _AnyUShortCode | _AnyULongCode | _AnyULongLongCode
+    | _AnyUIntCCode | _AnyUIntPCode
+)
+AnyUnsignedIntegerValue: TypeAlias = (
+    _AnyUnsignedIntegerNP[_NB_unsignedinteger]
+    | _AnyUnsignedIntegerCT
+)
+AnyUnsignedIntegerType: TypeAlias = (
+    _AnyUnsignedIntegerCode
+    | _DualType[
+        _AnyUnsignedIntegerNP[_NB_unsignedinteger],
+        AnyUnsignedIntegerValue[_NB_unsignedinteger],
+    ]
+)
+
+
+#
+# signed integers
+#
+
+# int8
+_AnyInt8NP: TypeAlias = np.int8
+_AnyInt8Name: TypeAlias = Literal['int8']
+_AnyInt8Char: TypeAlias = Literal['i1', '=i1', '<i1', '>i1']
+_AnyInt8Code: TypeAlias = _AnyInt8Name | _AnyInt8Char
+AnyInt8Value: TypeAlias = _AnyInt8NP | ct.c_int8
+AnyInt8Type: TypeAlias = _AnyInt8Code | _DualType[_AnyInt8NP, AnyInt8Value]
+
+# int16
+_AnyInt16NP: TypeAlias = np.int16
+_AnyInt16Name: TypeAlias = Literal['int16']
+_AnyInt16Char: TypeAlias = Literal['i2', '=i2', '<i2', '>i2']
+_AnyInt16Code: TypeAlias = _AnyInt16Name | _AnyInt16Char
+AnyInt16Value: TypeAlias = _AnyInt16NP | ct.c_int16
+AnyInt16Type: TypeAlias = _AnyInt16Code | _DualType[_AnyInt16NP, AnyInt16Value]
+
+# int32
+_AnyInt32NP: TypeAlias = np.int32
+_AnyInt32Name: TypeAlias = Literal['int32']
+_AnyInt32Char: TypeAlias = Literal['i4', '=i4', '<i4', '>i4']
+_AnyInt32Code: TypeAlias = _AnyInt32Name | _AnyInt32Char
+AnyInt32Value: TypeAlias = _AnyInt32NP | ct.c_int32
+AnyInt32Type: TypeAlias = _AnyInt32Code | _DualType[_AnyInt32NP, AnyInt32Value]
+
+# int64
+_AnyInt64NP: TypeAlias = np.int64
+_AnyInt64Name: TypeAlias = Literal['int64']
+_AnyInt64Char: TypeAlias = Literal['i8', '=i8', '<i8', '>i8']
+_AnyInt64Code: TypeAlias = _AnyInt64Name | _AnyInt64Char
+AnyInt64Value: TypeAlias = _AnyInt64NP | ct.c_int64
+AnyInt64Type: TypeAlias = _AnyInt64Code | _DualType[_AnyInt64NP, AnyInt64Value]
+
+# byte
+_AnyByteNP: TypeAlias = np.byte
+_AnyByteName: TypeAlias = Literal['byte']
+_AnyByteChar: TypeAlias = Literal['b', '=b', '<b', '>b']
+_AnyByteCode: TypeAlias = _AnyByteName | _AnyByteChar
+AnyByteValue: TypeAlias = _AnyByteNP | ct.c_byte
+AnyByteType: TypeAlias = _AnyByteCode | _DualType[_AnyByteNP, AnyByteValue]
+
+# short
+_AnyShortNP: TypeAlias = np.short
+_AnyShortName: TypeAlias = Literal['short']
+_AnyShortChar: TypeAlias = Literal['h', '=h', '<h', '>h']
+_AnyShortCode: TypeAlias = _AnyShortName | _AnyShortChar
+AnyShortValue: TypeAlias = _AnyShortNP | ct.c_short
+AnyShortType: TypeAlias = _AnyShortCode | _DualType[_AnyShortNP, AnyShortValue]
+
+# intc
+_AnyIntCNP: TypeAlias = np.intc
+_AnyIntCName: TypeAlias = Literal['intc']
+_AnyIntCChar: TypeAlias = Literal['i', '=i', '<i', '>i']
+_AnyIntCCode: TypeAlias = _AnyIntCName | _AnyIntCChar
+AnyIntCValue: TypeAlias = _AnyIntCNP | ct.c_int
+AnyIntCType: TypeAlias = _AnyIntCCode | _DualType[_AnyIntCNP, AnyIntCValue]
+
+# intp (or int_ if numpy>=2)
+_AnyIntPNP: TypeAlias = np.intp
+_AnyIntPCT: TypeAlias = ct.c_ssize_t
+if _NP_V2:
+    _AnyIntPName: TypeAlias = Literal['intp', 'int', 'int_']
+    _AnyIntPChar: TypeAlias = Literal['n', '=n', '<n', '>n']
+else:
+    _AnyIntPName: TypeAlias = Literal['intp', 'int0']
+    _AnyIntPChar: TypeAlias = Literal['p', '=p', '<p', '>p']
+_AnyIntPCode: TypeAlias = _AnyIntPName | _AnyIntPChar
+if _NP_V2:
+    AnyIntPValue: TypeAlias = _AnyIntPNP | _AnyIntPCT | int
+else:
+    AnyIntPValue: TypeAlias = _AnyIntPNP | _AnyIntPCT
+AnyIntPType: TypeAlias = _AnyIntPCode | _DualType[np.intp, AnyIntPValue]
+
+# long (or int_ if numpy<2)
+if _NP_V2:
+    _AnyLongNP: TypeAlias = np.long
+else:
+    _AnyLongNP: TypeAlias = np.int_
+_AnyLongCT: TypeAlias = ct.c_long
+if _NP_V2:
+    _AnyLongName: TypeAlias = Literal['long']
+else:
+    _AnyLongName: TypeAlias = Literal['long', 'int', 'int_']
+_AnyLongChar: TypeAlias = Literal['l', '=l', '<l', '>l']
+_AnyLongCode: TypeAlias = _AnyLongName | _AnyLongChar
+if _NP_V2:
+    AnyLongValue: TypeAlias = _AnyLongNP | _AnyLongCT
+else:
+    AnyLongValue: TypeAlias = _AnyLongNP | _AnyLongCT | int
+AnyLongType: TypeAlias = _AnyLongCode | _DualType[_AnyLongNP, AnyLongValue]
+
+# longlong
+_AnyLongLongNP: TypeAlias = np.longlong
+_AnyLongLongName: TypeAlias = Literal['longlong']
+_AnyLongLongChar: TypeAlias = Literal['q', '=q', '<q', '>q']
+_AnyLongLongCode: TypeAlias = _AnyLongLongName | _AnyLongLongChar
+AnyLongLongValue: TypeAlias = _AnyLongLongNP | ct.c_longlong
+AnyLongLongType: TypeAlias = (
+    _AnyLongLongCode
+    | _DualType[_AnyLongLongNP, AnyLongLongValue]
+)
+
+# signedinteger
+_NB_signedinteger = TypeVar(
+    '_NB_signedinteger',
+    bound='npt.NBitBase',
+    default=Any,
+)
+_AnySignedIntegerNP = np.signedinteger[_NB_signedinteger]
+# fmt: off
+_AnySignedIntegerCT: TypeAlias = (
+    ct.c_int8 | ct.c_int16 | ct.c_int32 | ct.c_int64
+    | ct.c_byte | ct.c_short | ct.c_int | ct.c_long | ct.c_longlong
+    | ct.c_ssize_t
+)
+# fmt: on
+_AnySignedIntegerPY: TypeAlias = int
+# fmt: off
+_AnySignedIntegerCode: TypeAlias = (
+    _AnyInt8Code | _AnyInt16Code | _AnyInt32Code | _AnyInt64Code
+    | _AnyByteCode | _AnyShortCode | _AnyLongCode | _AnyLongLongCode
+    | _AnyIntCCode | _AnyIntPCode
+)
+# fmt: on
+AnySignedIntegerValue: TypeAlias = (
+    _AnySignedIntegerNP[_NB_signedinteger]
+    | _AnySignedIntegerCT
+    | _AnySignedIntegerPY
+)
+AnySignedIntegerType: TypeAlias = (
+    _AnySignedIntegerCode
+    | _DualType[
+        _AnySignedIntegerNP[_NB_signedinteger],
+        AnySignedIntegerValue[_NB_signedinteger],
+    ]
+)
+
+
+#
+# (unsigned | signed) integers
+#
+
+# integer
+_NB_integer = TypeVar('_NB_integer', bound='npt.NBitBase', default=Any)
+_AnyIntegerNP: TypeAlias = np.integer[_NB_integer]
+_AnyIntegerCT: TypeAlias = _AnyUnsignedIntegerCT | _AnySignedIntegerCT
+_AnyIntegerPY: TypeAlias = _AnySignedIntegerPY
+_AnyIntegerCode: TypeAlias = _AnyUnsignedIntegerCode | _AnySignedIntegerCode
+AnyIntegerValue: TypeAlias = (
+    _AnyIntegerNP[_NB_integer]
+    | _AnyIntegerCT
+    | _AnyIntegerPY
+)
+AnyIntegerType: TypeAlias = (
+    _AnyIntegerCode
+    | _DualType[_AnyIntegerNP[_NB_integer], AnyIntegerValue[_NB_integer]]
+)
+
+
+#
+# real floats
+#
+
+# float16
+_AnyFloat16NP: TypeAlias = np.float16
+_AnyFloat16Name: TypeAlias = Literal['float16']
+_AnyFloat16Char: TypeAlias = Literal['f2', '=f2', '<f2', '>f2']
+_AnyFloat16Code: TypeAlias = _AnyFloat16Name | _AnyFloat16Char
+AnyFloat16Value: TypeAlias = _AnyFloat16NP
+AnyFloat16Type: TypeAlias = _AnyFloat16Code | _SoloType[_AnyFloat16NP]
+
+# float32
+_AnyFloat32NP: TypeAlias = np.float32
+_AnyFloat32Name: TypeAlias = Literal['float32']
+_AnyFloat32Char: TypeAlias = Literal['f4', '=f4', '<f4', '>f4']
+_AnyFloat32Code: TypeAlias = _AnyFloat32Name | _AnyFloat32Char
+AnyFloat32Value: TypeAlias = _AnyFloat32NP | ct.c_float
+AnyFloat32Type: TypeAlias = _AnyFloat32Code | _SoloType[_AnyFloat32NP]
+
+# float64
+_AnyFloat64NP: TypeAlias = np.float64
+_AnyFloat64Name: TypeAlias = Literal['float64']
+_AnyFloat64Char: TypeAlias = Literal['f8', '=f8', '<f8', '>f8']
+_AnyFloat64Code: TypeAlias = _AnyFloat64Name | _AnyFloat64Char
+AnyFloat64Value: TypeAlias = _AnyFloat64NP | ct.c_double | float
+AnyFloat64Type: TypeAlias = _AnyFloat64Code | _SoloType[_AnyFloat64NP]
+
+# half
+_AnyHalfNP: TypeAlias = np.half
+_AnyHalfName: TypeAlias = Literal['half']
+_AnyHalfChar: TypeAlias = Literal['e', '=e', '<e', '>e']
+_AnyHalfCode: TypeAlias = _AnyHalfName | _AnyHalfChar
+AnyHalfValue: TypeAlias = _AnyHalfNP
+AnyHalfType: TypeAlias = _AnyHalfCode | _SoloType[_AnyHalfNP]
+
+# single
+_AnySingleNP: TypeAlias = np.single
+_AnySingleName: TypeAlias = Literal['single']
+_AnySingleChar: TypeAlias = Literal['f', '=f', '<f', '>f']
+_AnySingleCode: TypeAlias = _AnySingleName | _AnySingleChar
+AnySingleValue: TypeAlias = _AnySingleNP | ct.c_float
+AnySingleType: TypeAlias = (
+    _AnySingleCode
+    | _DualType[_AnySingleNP, AnySingleValue]
+)
+
+# double
+_AnyDoubleNP: TypeAlias = np.double
+if _NP_V2:
+    _AnyDoubleName: TypeAlias = Literal['double', 'float']
+else:
+    _AnyDoubleName: TypeAlias = Literal['double', 'float', 'float_']
+_AnyDoubleChar: TypeAlias = Literal['d', '=d', '<d', '>d']
+_AnyDoubleCode: TypeAlias = _AnyDoubleName | _AnyDoubleChar
+AnyDoubleValue: TypeAlias = _AnyDoubleNP | ct.c_double | float
+AnyDoubleType: TypeAlias = (
+    _AnyDoubleCode
+    | _DualType[_AnyDoubleNP, AnyDoubleValue]
+)
+
+# longdouble
+_AnyLongDoubleNP: TypeAlias = np.longdouble
+if _NP_V2:
+    _AnyLongDoubleName: TypeAlias = Literal['longdouble']
+else:
+    _AnyLongDoubleName: TypeAlias = Literal['longdouble', 'longfloat']
+_AnyLongDoubleChar: TypeAlias = Literal['g', '=g', '<g', '>g']
+_AnyLongDoubleCode: TypeAlias = _AnyLongDoubleName | _AnyLongDoubleChar
+AnyLongDoubleValue: TypeAlias = _AnyLongDoubleNP | ct.c_longdouble
+AnyLongDoubleType: TypeAlias = (
+    _AnyLongDoubleCode
+    | _DualType[_AnyLongDoubleNP, AnyLongDoubleValue]
+)
+
+# floating
+_NB_floating = TypeVar('_NB_floating', bound='npt.NBitBase', default=Any)
+_AnyFloatingNP: TypeAlias = np.floating[_NB_floating]
+_AnyFloatingCT: TypeAlias = ct.c_float | ct.c_double | ct.c_longdouble
+_AnyFloatingPY: TypeAlias = float
+# fmt: off
+_AnyFloatingCode: TypeAlias = (
+    _AnyFloat16Code | _AnyFloat32Code | _AnyFloat64Code
+    | _AnyHalfCode | _AnySingleCode | _AnyDoubleCode | _AnyLongDoubleCode
+)
+# fmt: on
+AnyFloatingValue: TypeAlias = (
+    _AnyFloatingNP[_NB_floating]
+    | _AnyFloatingCT
+    | _AnyFloatingPY
+)
+AnyFloatingType: TypeAlias = (
+    _AnyFloatingCode
+    | _DualType[_AnyFloatingNP[_NB_floating], AnyFloatingValue[_NB_floating]]
+)
+
+
+#
+# complex floats
+#
+
+# complex64
+_AnyComplex64NP: TypeAlias = np.complex64
+_AnyComplex64Name: TypeAlias = Literal['complex64']
+_AnyComplex64Char: TypeAlias = Literal['c8', '=c8', '<c8', '>c8']
+_AnyComplex64Code: TypeAlias = _AnyComplex64Name | _AnyComplex64Char
+AnyComplex64Value: TypeAlias = _AnyComplex64NP
+AnyComplex64Type: TypeAlias = _AnyComplex64Code | _SoloType[_AnyComplex64NP]
+
+# complex128
+_AnyComplex128NP: TypeAlias = np.complex128
+_AnyComplex128PY: TypeAlias = complex
+_AnyComplex128Name: TypeAlias = Literal['complex128']
+_AnyComplex128Char: TypeAlias = Literal['c16', '=c16', '<c16', '>c16']
+_AnyComplex128Code: TypeAlias = _AnyComplex128Name | _AnyComplex128Char
+AnyComplex128Value: TypeAlias = _AnyComplex128NP | _AnyComplex128PY
+AnyComplex128Type: TypeAlias = _AnyComplex128Code | _SoloType[_AnyComplex128NP]
+
+# csingle
+_AnyCSingleNP: TypeAlias = np.csingle
+if _NP_V2:
+    _AnyCSingleName: TypeAlias = Literal['csingle']
+else:
+    _AnyCSingleName: TypeAlias = Literal['csingle', 'singlecomplex']
+_AnyCSingleChar: TypeAlias = Literal['F', '=F', '<F', '>F']
+_AnyCSingleCode: TypeAlias = _AnyCSingleName | _AnyCSingleChar
+AnyCSingleValue: TypeAlias = _AnyCSingleNP
+AnyCSingleType: TypeAlias = _AnyCSingleCode | _SoloType[_AnyCSingleNP]
+
+# cdouble
+_AnyCDoubleNP: TypeAlias = np.cdouble
+_AnyCDoublePY: TypeAlias = complex
+if _NP_V2:
+    _AnyCDoubleName: TypeAlias = Literal['cdouble', 'complex']
+else:
+    # fmt: off
+    _AnyCDoubleName: TypeAlias = Literal[
+        'cdouble', 'cfloat',
+        'complex', 'complex_',
+    ]
+    # fmt: on
+_AnyCDoubleChar: TypeAlias = Literal['D', '=D', '<D', '>D']
+_AnyCDoubleCode: TypeAlias = _AnyCDoubleName | _AnyCDoubleChar
+AnyCDoubleValue: TypeAlias = _AnyCDoubleNP | _AnyCDoublePY
+AnyCDoubleType: TypeAlias = (
+    _AnyCDoubleCode
+    | _DualType[_AnyCDoubleNP, AnyCDoubleValue]
+)
+
+# clongdouble
+AnyCLongDoubleValueNP: TypeAlias = np.clongdouble
+if _NP_V2:
+    _AnyCLongDoubleName: TypeAlias = Literal['clongdouble']
+else:
+    _AnyCLongDoubleName: TypeAlias = Literal[
+        'clongdouble',
+        'clongfloat',
+        'longcomplex',
+    ]
+_AnyCLongDoubleChar: TypeAlias = Literal['G', '=G', '<G', '>G']
+_AnyCLongDoubleCode: TypeAlias = _AnyCLongDoubleName | _AnyCLongDoubleChar
+AnyCLongDoubleValue: TypeAlias = AnyCLongDoubleValueNP
+AnyCLongDoubleType: TypeAlias = (
+    _AnyCLongDoubleCode
+    | _SoloType[AnyCLongDoubleValueNP]
+)
+
+# complexfloating
+_NB1_complexfloating = TypeVar(
+    '_NB1_complexfloating',
+    bound='npt.NBitBase',
+    default=Any,
+)
+_NB2_complexfloating = TypeVar(
+    '_NB2_complexfloating',
+    bound='npt.NBitBase',
+    default=_NB1_complexfloating,
+)
+_AnyComplexFloatingNP: TypeAlias = np.complexfloating[
+    _NB1_complexfloating,
+    _NB2_complexfloating,
+]
+_AnyComplexFloatingPY: TypeAlias = complex
+# fmt: off
+_AnyComplexFloatingCode: TypeAlias = (
+    _AnyComplex64Code | _AnyComplex128Code
+    | _AnyCSingleCode | _AnyCDoubleCode | _AnyCLongDoubleCode
+)
+# fmt: on
+AnyComplexFloatingValue: TypeAlias = (
+    _AnyComplexFloatingNP[_NB1_complexfloating, _NB2_complexfloating]
+    | _AnyComplexFloatingPY
+)
+AnyComplexFloatingType: TypeAlias = (
+    _AnyComplexFloatingCode
+    | _DualType[
+        _AnyComplexFloatingNP[_NB1_complexfloating, _NB2_complexfloating],
+        AnyComplexFloatingValue[_NB1_complexfloating, _NB2_complexfloating],
+    ]
+)
+
+
+#
+# (real | complex) floats
+#
+
+# inexact
+_NB_inexact = TypeVar('_NB_inexact', bound='npt.NBitBase', default=Any)
+_AnyInexactNP: TypeAlias = np.inexact[_NB_inexact]
+_AnyInexactCT: TypeAlias = _AnyFloatingCT
+_AnyInexactPY: TypeAlias = _AnyFloatingPY | _AnyComplexFloatingPY
+_AnyInexactCode: TypeAlias = _AnyFloatingCode | _AnyComplexFloatingCode
+AnyInexactValue: TypeAlias = (
+    _AnyInexactNP[_NB_inexact]
+    | _AnyInexactCT
+    | _AnyInexactPY
+)
+AnyInexactType: TypeAlias = (
+    _AnyInexactCode
+    | _DualType[_AnyInexactNP[_NB_inexact], AnyInexactValue[_NB_inexact]]
+)
+
+
+#
+# integers | floats
+#
+
+# number
+_NB_number = TypeVar('_NB_number', bound='npt.NBitBase', default=Any)
+_AnyNumberNP: TypeAlias = np.number[_NB_number]
+_AnyNumberCT: TypeAlias = _AnyIntegerCT | _AnyInexactCT
+_AnyNumberPY: TypeAlias = _AnyIntegerPY | _AnyInexactPY
+_AnyNumberCode: TypeAlias = _AnyIntegerCode | _AnyInexactCode
+AnyNumberValue: TypeAlias = (
+    _AnyNumberNP[_NB_number]
+    | _AnyNumberCT
+    | _AnyNumberPY
+)
+AnyNumberType: TypeAlias = (
+    _AnyNumberCode
+    | _DualType[_AnyNumberNP[_NB_number], AnyNumberValue[_NB_number]]
+)
+
+
+#
+# temporal
+#
+
+# timedelta64
+_AnyTimedelta64NP: TypeAlias = np.timedelta64
+_AnyTimedelta64Name: TypeAlias = Literal['timedelta64']
+# fmt: off
+_AnyTimedelta64Char: TypeAlias = Literal[
+    'm', '=m', '<m', '>m',
+    'm8', '=m8', '<m8', '>m8',
+]
+# fmt: on
+_AnyTimedelta64Code: TypeAlias = _AnyTimedelta64Name | _AnyTimedelta64Char
+AnyTimedelta64Value: TypeAlias = _AnyTimedelta64NP
+AnyTimedelta64Type: TypeAlias = (
+    _AnyTimedelta64Code
+    | _SoloType[_AnyTimedelta64NP]
+)
+
+# datetime64
+_AnyDatetime64NP: TypeAlias = np.datetime64
+_AnyDatetime64Name: TypeAlias = Literal['datetime64']
+# fmt: off
+_AnyDatetime64Char: TypeAlias = Literal[
+    'M', '=M', '<M', '>M',
+    'M8', '=M8', '<M8', '>M8',
+]
+# fmt: on
+_AnyDatetime64Code: TypeAlias = _AnyDatetime64Name | _AnyDatetime64Char
+AnyDatetime64Value: TypeAlias = _AnyDatetime64NP
+AnyDatetime64Type: TypeAlias = _AnyDatetime64Code | _SoloType[_AnyDatetime64NP]
+
+
+#
+# character strings
+#
+
+# str
+_AnyStrNP: TypeAlias = np.str_
+_AnyStrPY: TypeAlias = str
+if _NP_V2:
+    _AnyStrName: TypeAlias = Literal['str', 'str_', 'unicode']
+else:
+    # fmt: off
+    _AnyStrName: TypeAlias = Literal[
+        'str', 'str_', 'str0',
+        'unicode', 'unicode_',
+    ]
+    # fmt: on
+_AnyStrChar: TypeAlias = Literal['U', '=U', '<U', '>U']
+_AnyStrCode: TypeAlias = _AnyStrName | _AnyStrChar
+AnyStrValue: TypeAlias = _AnyStrNP | _AnyStrPY
+AnyStrType: TypeAlias = _AnyStrCode | _DualType[_AnyStrNP, AnyStrValue]
+
+# bytes
+_AnyBytesNP: TypeAlias = np.bytes_
+_AnyBytesPY: TypeAlias = bytes
+if _NP_V2:
+    _AnyBytesName: TypeAlias = Literal['bytes', 'bytes_']
+else:
+    _AnyBytesName: TypeAlias = Literal['bytes', 'bytes_', 'bytes0']
+_AnyBytesChar: TypeAlias = Literal['S', '=S', '<S', '>S']
+_AnyBytesCode: TypeAlias = _AnyBytesName | _AnyBytesChar
+AnyBytesValue: TypeAlias = _AnyBytesNP | _AnyBytesPY
+AnyBytesType: TypeAlias = _AnyBytesCode | _DualType[_AnyBytesNP, AnyBytesValue]
+
+# character
+_AnyCharacterNP: TypeAlias = np.character
+_AnyCharacterCT: TypeAlias = ct.c_char
+_AnyCharacterPY: TypeAlias = _AnyStrPY | _AnyBytesPY
+_AnyCharacterCode: TypeAlias = _AnyStrCode | _AnyBytesCode
+AnyCharacterValue: TypeAlias = (
+    _AnyCharacterNP
+    | _AnyCharacterCT
+    | _AnyCharacterPY
+)
+AnyCharacterType: TypeAlias = (
+    _AnyCharacterCode
+    | _DualType[_AnyCharacterNP, AnyCharacterValue]
+)
+
+# void
+_AnyVoidNP: TypeAlias = np.void
+if _NP_V2:
+    _AnyVoidName: TypeAlias = Literal['void']
+else:
+    _AnyVoidName: TypeAlias = Literal['void', 'void0']
+_AnyVoidChar: TypeAlias = Literal['V', '=V', '<V', '>V']
+_AnyVoidCode: TypeAlias = _AnyVoidName | _AnyVoidChar
+AnyVoidValue: TypeAlias = _AnyVoidNP
+AnyVoidType: TypeAlias = _AnyVoidCode | _SoloType[_AnyVoidNP]
+
+# flexible
+_AnyFlexibleNP: TypeAlias = np.flexible
+_AnyFlexibleCT: TypeAlias = _AnyCharacterCT
+_AnyFlexiblePY: TypeAlias = _AnyCharacterPY
+_AnyFlexibleCode: TypeAlias = _AnyCharacterCode | _AnyVoidCode
+AnyFlexibleValue: TypeAlias = _AnyFlexibleNP | _AnyFlexibleCT | _AnyFlexiblePY
+AnyFlexibleType: TypeAlias = (
+    _AnyFlexibleCode
+    | _DualType[_AnyFlexibleNP, AnyFlexibleValue]
+)
+
+
+#
+# other types
+#
+
+# bool_
+_AnyBoolNP: TypeAlias = np.bool_
+_AnyBoolCT: TypeAlias = ct.c_bool
+_AnyBoolPY: TypeAlias = bool
+if _NP_V2:
+    _AnyBoolName: TypeAlias = Literal['bool', 'bool_']
+else:
+    _AnyBoolName: TypeAlias = Literal['bool', 'bool_', 'bool8']
+_AnyBoolChar: TypeAlias = Literal['?', '=?', '<?', '>?']
+_AnyBoolCode: TypeAlias = _AnyBoolName | _AnyBoolChar
+AnyBoolValue: TypeAlias = _AnyBoolNP | _AnyBoolCT | _AnyBoolPY
+AnyBoolType: TypeAlias = _AnyBoolCode | _DualType[_AnyBoolNP, AnyBoolValue]
+
+# object
+_AnyObjectNP: TypeAlias = np.object_
+if TYPE_CHECKING:
+    _AnyObjectCT: TypeAlias = ct.py_object[Any]
+else:
+    _AnyObjectCT: TypeAlias = ct.py_object
+_AnyObjectPY: TypeAlias = object
+_AnyObjectName: TypeAlias = Literal['object', 'object_']
+_AnyObjectChar: TypeAlias = Literal['O', '=O', '<O', '>O']
+_AnyObjectCode: TypeAlias = _AnyObjectName | _AnyObjectChar
+AnyObjectValue: TypeAlias = _AnyObjectNP | _AnyObjectCT | _AnyObjectPY
+AnyObjectType: TypeAlias = (
+    _AnyObjectCode
+    | _DualType[_AnyObjectNP, AnyObjectValue]
+)
+
+
+#
+# any scalar
+#
+
+# generic
+_NB_generic = TypeVar('_NB_generic', bound='npt.NBitBase', default=Any)
+_AnyGenericNP: TypeAlias = (
+    _AnyBoolNP
+    | _AnyObjectNP
+    | _AnyNumberNP[_NB_generic]
+    | _AnyFlexibleNP
+    | np.generic  # catch-all for any other user-defined scalar types
+)
+_AnyGenericCode: TypeAlias = (
+    _AnyBoolCode
+    | _AnyNumberCode
+    | _AnyFlexibleCode
+    | _AnyObjectCode
+)
+AnyGenericValue: TypeAlias = (
+    AnyBoolValue
+    | AnyNumberValue[_NB_generic]
+    | AnyFlexibleValue
+    | AnyObjectValue
+    | np.generic  # catch-all for any other user-defined scalar types
+)
+AnyGenericType: TypeAlias = (
+    _AnyGenericCode
+    | _DualType[_AnyGenericNP[_NB_generic], AnyGenericValue[_NB_generic]]
+)
diff --git a/optype/numpy/_ufunc.py b/optype/numpy/_ufunc.py
new file mode 100644
index 00000000..7bc12ff6
--- /dev/null
+++ b/optype/numpy/_ufunc.py
@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+import sys
+from collections.abc import Callable as CanCall
+from typing import Any, Final, Literal, Protocol, TypeAlias
+
+import numpy as np
+
+
+if sys.version_info >= (3, 13):
+    from typing import Protocol, TypeVar, runtime_checkable
+else:
+    from typing_extensions import Protocol, TypeVar, runtime_checkable
+
+
+_NP_V2: Final[bool] = np.__version__.startswith('2.')
+
+
+_Fn_AnyUfunc = TypeVar(
+    '_Fn_AnyUfunc',
+    infer_variance=True,
+    bound=CanCall[..., Any],
+    default=CanCall[..., Any],
+)
+_Nin_AnyUfunc = TypeVar(
+    '_Nin_AnyUfunc',
+    infer_variance=True,
+    bound=int,
+    default=int,
+)
+_Nout_AnyUfunc = TypeVar(
+    '_Nout_AnyUfunc',
+    infer_variance=True,
+    bound=int,
+    default=int,
+)
+_Sig_AnyUfunc = TypeVar(
+    '_Sig_AnyUfunc',
+    infer_variance=True,
+    bound=str | None,
+    default=str | None,
+)
+_Id_AnyUfunc = TypeVar(
+    '_Id_AnyUfunc',
+    infer_variance=True,
+    bound=complex | str | bytes | None,
+    default=complex | str | bytes | None,
+)
+
+
+@runtime_checkable
+class AnyUFunc(Protocol[
+    _Fn_AnyUfunc,
+    _Nin_AnyUfunc,
+    _Nout_AnyUfunc,
+    _Sig_AnyUfunc,
+    _Id_AnyUfunc,
+]):
+    """
+    A generic interface for `numpy.ufunc` "universal function" instances,
+    e.g. `numpy.exp`, `numpy.add`, `numpy.frexp`, `numpy.divmod`.
+
+    This also includes gufunc's (generalized universion functions), which
+    have a specified `signature`, and aren't necessarily element-wise
+    functions (which "regular" ufuncs are).
+    At the moment, the only gufuncs within numpy are `numpy.matmul`, and
+    `numpy.vecdot` (since `numpy>=2`).
+
+    TODO:
+        Attempt property overloading (based on type params) of e.g. `nargs`,
+        using descriptors.
+    """
+    @property
+    def __call__(self, /) -> _Fn_AnyUfunc: ...
+
+    # The number of positional-only parameters, within numpy this is either 1
+    # or 2, but might be more for 3rd party ufuncs.
+    @property
+    def nin(self, /) -> _Nin_AnyUfunc: ...
+    # The number of output values, within numpy this is either 1 or 2.
+    @property
+    def nout(self, /) -> _Nout_AnyUfunc: ...
+    # A string i.f.f. this is a gufunc (generalized ufunc).
+    @property
+    def signature(self, /) -> _Sig_AnyUfunc: ...
+
+    # If `signature is None and nin == 2 and nout == 1`, this *may* be set to
+    # a python scalar s.t. `self(x, identity) == x` for all possible `x`.
+    # Within numpy==2.0.0, this is only the case for `multiply` (`1`),
+    # `logaddexp` (`-inf`), `logaddexp2` (`-inf`), `logical_and` (`True`),
+    # and `bitwise_and` (`-1`).
+    # Note that the `complex` return annotation implicitly includes
+    # `bool | int | float` (these are its supertypes).
+    @property
+    def identity(self, /) -> _Id_AnyUfunc: ...
+    # Within numpy this is always `nin + nout`, since each output value comes
+    # with a corresponding (optional) `out` parameter.
+    @property
+    def nargs(self, /) -> int: ...
+    # Equivalent to `len(types)`, within numpy this is at most 24, but for 3rd
+    # party ufuncs it could be more.
+    @property
+    def ntypes(self, /) -> int: ...
+    # A list of strings (`LiteralString` can't be used for compatibility
+    # reasons), with signatures in terms of `numpy.dtype.char`, that match
+    # `r'(\w{nin})->(\w{nout})'`.
+    # For instance, `np.frexp` has `['e->ei', 'f->fi', 'd->di', 'g->gi']`.
+    # Note that the `len` of each `types` elements is `nin + nout + 2`.
+    # Also note that the elements aren't necessarily unique, because the
+    # available data types are system dependent.
+    @property
+    def types(self, /) -> list[str]: ...
+
+    # The following methods are incorrectly typed (`numpy/_typing/_ufunc.pyi`);
+    # they should always be methods that `typing.NoReturn` i.f.f. not available
+    # for the ufunc's `nin` and `nout` (instead of `None`), so that they'd
+    # match the runtime behaviour (i.e. `raise ValueError` when called)
+
+    # raises `ValueError` i.f.f. `nout != 1 or bool(signature)`
+    @property
+    def at(self, /) -> CanCall[..., None] | None: ...
+    # raises `ValueError` i.f.f. `nin != 2 or nout != 1 or bool(signature)`
+    @property
+    def reduce(self, /) -> CanCall[..., Any] | None: ...
+    # raises `ValueError` i.f.f. `nin != 2 or nout != 1 or bool(signature)`
+    @property
+    def reduceat(self, /) -> CanCall[..., np.ndarray[Any, Any]] | None: ...
+    # raises `ValueError` i.f.f. `nin != 2 or nout != 1 or bool(signature)`
+    @property
+    def accumulate(self, /) -> CanCall[..., np.ndarray[Any, Any]] | None: ...
+    # raises `ValueError` i.f.f. `nin != 2 or nout != 1 or bool(signature)`
+    @property
+    def outer(self, /) -> CanCall[..., Any] | None: ...
+
+
+if _NP_V2:
+    _UFuncMethod: TypeAlias = Literal[
+        '__call__',
+        'reduce',
+        'reduceat',
+        'accumulate',
+        'outer',
+        'at',
+    ]
+else:
+    _UFuncMethod: TypeAlias = Literal[
+        '__call__',
+        'reduce',
+        'reduceat',
+        'accumulate',
+        'outer',
+        'inner',
+    ]
+
+
+_Fn_CanArrayUFunc = TypeVar(
+    '_Fn_CanArrayUFunc',
+    infer_variance=True,
+    bound=AnyUFunc,
+    default=Any,
+)
+
+
+@runtime_checkable
+class CanArrayUFunc(Protocol[_Fn_CanArrayUFunc]):
+    """
+    Interface for ufunc operands.
+
+    See Also:
+        - https://numpy.org/devdocs/reference/arrays.classes.html
+    """
+    def __array_ufunc__(
+        self,
+        /,
+        ufunc: _Fn_CanArrayUFunc,
+        method: _UFuncMethod,
+        *args: Any,
+        **kwargs: Any,
+    ) -> Any: ...
diff --git a/poetry.lock b/poetry.lock
index 2b324b26..bc569b5d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -235,6 +235,60 @@ files = [
     {file = "nodejs_wheel_binaries-20.14.0.tar.gz", hash = "sha256:38e4b1b4dee08898da5fb9d813d1653162fd0e6f4ec83dc6615b2432f1fa90aa"},
 ]
 
+[[package]]
+name = "numpy"
+version = "2.0.0"
+description = "Fundamental package for array computing in Python"
+optional = false
+python-versions = ">=3.9"
+files = [
+    {file = "numpy-2.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:04494f6ec467ccb5369d1808570ae55f6ed9b5809d7f035059000a37b8d7e86f"},
+    {file = "numpy-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2635dbd200c2d6faf2ef9a0d04f0ecc6b13b3cad54f7c67c61155138835515d2"},
+    {file = "numpy-2.0.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:0a43f0974d501842866cc83471bdb0116ba0dffdbaac33ec05e6afed5b615238"},
+    {file = "numpy-2.0.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:8d83bb187fb647643bd56e1ae43f273c7f4dbcdf94550d7938cfc32566756514"},
+    {file = "numpy-2.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79e843d186c8fb1b102bef3e2bc35ef81160ffef3194646a7fdd6a73c6b97196"},
+    {file = "numpy-2.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d7696c615765091cc5093f76fd1fa069870304beaccfd58b5dcc69e55ef49c1"},
+    {file = "numpy-2.0.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b4c76e3d4c56f145d41b7b6751255feefae92edbc9a61e1758a98204200f30fc"},
+    {file = "numpy-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acd3a644e4807e73b4e1867b769fbf1ce8c5d80e7caaef0d90dcdc640dfc9787"},
+    {file = "numpy-2.0.0-cp310-cp310-win32.whl", hash = "sha256:cee6cc0584f71adefe2c908856ccc98702baf95ff80092e4ca46061538a2ba98"},
+    {file = "numpy-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:ed08d2703b5972ec736451b818c2eb9da80d66c3e84aed1deeb0c345fefe461b"},
+    {file = "numpy-2.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad0c86f3455fbd0de6c31a3056eb822fc939f81b1618f10ff3406971893b62a5"},
+    {file = "numpy-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7f387600d424f91576af20518334df3d97bc76a300a755f9a8d6e4f5cadd289"},
+    {file = "numpy-2.0.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:34f003cb88b1ba38cb9a9a4a3161c1604973d7f9d5552c38bc2f04f829536609"},
+    {file = "numpy-2.0.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:b6f6a8f45d0313db07d6d1d37bd0b112f887e1369758a5419c0370ba915b3871"},
+    {file = "numpy-2.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f64641b42b2429f56ee08b4f427a4d2daf916ec59686061de751a55aafa22e4"},
+    {file = "numpy-2.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7039a136017eaa92c1848152827e1424701532ca8e8967fe480fe1569dae581"},
+    {file = "numpy-2.0.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:46e161722e0f619749d1cd892167039015b2c2817296104487cd03ed4a955995"},
+    {file = "numpy-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0e50842b2295ba8414c8c1d9d957083d5dfe9e16828b37de883f51fc53c4016f"},
+    {file = "numpy-2.0.0-cp311-cp311-win32.whl", hash = "sha256:2ce46fd0b8a0c947ae047d222f7136fc4d55538741373107574271bc00e20e8f"},
+    {file = "numpy-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:fbd6acc766814ea6443628f4e6751d0da6593dae29c08c0b2606164db026970c"},
+    {file = "numpy-2.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:354f373279768fa5a584bac997de6a6c9bc535c482592d7a813bb0c09be6c76f"},
+    {file = "numpy-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d2f62e55a4cd9c58c1d9a1c9edaedcd857a73cb6fda875bf79093f9d9086f85"},
+    {file = "numpy-2.0.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:1e72728e7501a450288fc8e1f9ebc73d90cfd4671ebbd631f3e7857c39bd16f2"},
+    {file = "numpy-2.0.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:84554fc53daa8f6abf8e8a66e076aff6ece62de68523d9f665f32d2fc50fd66e"},
+    {file = "numpy-2.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c73aafd1afca80afecb22718f8700b40ac7cab927b8abab3c3e337d70e10e5a2"},
+    {file = "numpy-2.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49d9f7d256fbc804391a7f72d4a617302b1afac1112fac19b6c6cec63fe7fe8a"},
+    {file = "numpy-2.0.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0ec84b9ba0654f3b962802edc91424331f423dcf5d5f926676e0150789cb3d95"},
+    {file = "numpy-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:feff59f27338135776f6d4e2ec7aeeac5d5f7a08a83e80869121ef8164b74af9"},
+    {file = "numpy-2.0.0-cp312-cp312-win32.whl", hash = "sha256:c5a59996dc61835133b56a32ebe4ef3740ea5bc19b3983ac60cc32be5a665d54"},
+    {file = "numpy-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a356364941fb0593bb899a1076b92dfa2029f6f5b8ba88a14fd0984aaf76d0df"},
+    {file = "numpy-2.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e61155fae27570692ad1d327e81c6cf27d535a5d7ef97648a17d922224b216de"},
+    {file = "numpy-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4554eb96f0fd263041baf16cf0881b3f5dafae7a59b1049acb9540c4d57bc8cb"},
+    {file = "numpy-2.0.0-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:903703372d46bce88b6920a0cd86c3ad82dae2dbef157b5fc01b70ea1cfc430f"},
+    {file = "numpy-2.0.0-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:3e8e01233d57639b2e30966c63d36fcea099d17c53bf424d77f088b0f4babd86"},
+    {file = "numpy-2.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cde1753efe513705a0c6d28f5884e22bdc30438bf0085c5c486cdaff40cd67a"},
+    {file = "numpy-2.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821eedb7165ead9eebdb569986968b541f9908979c2da8a4967ecac4439bae3d"},
+    {file = "numpy-2.0.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9a1712c015831da583b21c5bfe15e8684137097969c6d22e8316ba66b5baabe4"},
+    {file = "numpy-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9c27f0946a3536403efb0e1c28def1ae6730a72cd0d5878db38824855e3afc44"},
+    {file = "numpy-2.0.0-cp39-cp39-win32.whl", hash = "sha256:63b92c512d9dbcc37f9d81b123dec99fdb318ba38c8059afc78086fe73820275"},
+    {file = "numpy-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:3f6bed7f840d44c08ebdb73b1825282b801799e325bcbdfa6bc5c370e5aecc65"},
+    {file = "numpy-2.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9416a5c2e92ace094e9f0082c5fd473502c91651fb896bc17690d6fc475128d6"},
+    {file = "numpy-2.0.0-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:17067d097ed036636fa79f6a869ac26df7db1ba22039d962422506640314933a"},
+    {file = "numpy-2.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38ecb5b0582cd125f67a629072fed6f83562d9dd04d7e03256c9829bdec027ad"},
+    {file = "numpy-2.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cef04d068f5fb0518a77857953193b6bb94809a806bd0a14983a8f12ada060c9"},
+    {file = "numpy-2.0.0.tar.gz", hash = "sha256:cf5d1c9e6837f8af9f92b6bd3e86d513cdc11f60fd62185cc49ec7d1aba34864"},
+]
+
 [[package]]
 name = "packaging"
 version = "24.1"
@@ -608,7 +662,10 @@ platformdirs = ">=3.9.1,<5"
 docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
 test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
 
+[extras]
+numpy = ["numpy"]
+
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.10"
-content-hash = "12fb15394b5ce4276fdbd47346b18dd3c2c579d25340615d9495268b42ca0f5f"
+content-hash = "4c0974944efa31778f0ccf6ed5b7d60b178c4b51b5ecad9315aed22b1c1b2cf6"
diff --git a/pyproject.toml b/pyproject.toml
index 8d8fea2c..63ef6753 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,16 +21,25 @@ classifiers = [
 ]
 repository = "https://github.com/jorenham/optype/"
 documentation = "https://github.com/jorenham/optype?tab=readme-ov-file#optype"
+
 [tool.poetry.urls]
 "Bug Tracker" = "https://github.com/jorenham/optype/issues"
 "Changelog" = "https://github.com/jorenham/optype/releases"
 
+[tool.poetry.extras]
+numpy = ["numpy"]
+
 [tool.poetry.dependencies]
+# bump to ^3.11 after 2025-04-04
+# bump to ^3.12 after 2026-04-24
 python = "^3.10"
 typing-extensions = {version = ">=4.7", python = "<3.13"}
 
+# https://scientific-python.org/specs/spec-0000/
+numpy = {version = ">=1.24,<3", optional = true}
+
 [tool.poetry.group.dev.dependencies]
-pre-commit = '^3.7.1'
+pre-commit = "^3.7.1"
 tox = "^4.15.1"
 
 [tool.poetry.group.lint.dependencies]
@@ -39,11 +48,14 @@ codespell = "^2.3.0"
 ruff = "^0.4.9"
 sp-repo-review = {version = "^2024.4.23", extras = ["cli"]}
 typing-extensions = "*"
+numpy = ">=2,<3"
 
 [tool.poetry.group.test.dependencies]
 pytest = "^8.2.2"
-typing-extensions = "*"
 beartype = "^0.18.5"
+typing-extensions = "*"
+numpy = ">=2,<3"
+
 
 [tool.poetry.group.test-github]
 optional = true
@@ -53,24 +65,29 @@ pytest-github-actions-annotate-failures = ">=0.2.0,<1.0"
 
 [tool.codespell]
 context = 2
-skip = "poetry.lock"
-ignore-words-list = "cange"  # CanGe
+skip = "poetry.lock,pyproject.toml"
+ignore-words-list = "CanGe,nin,ND"  # `.CanGe`, `numpy.ufunc.nin`, `.numpy`
 
 
 [tool.basedpyright]
 include = ["optype", "examples", "tests"]
+ignore = ["**/.venv"]
 stubPath = "."
 venvPath = "."
 venv = ".venv"
 pythonVersion = "3.10"
 pythonPlatform = "All"
 typeCheckingMode = "all"
+defineConstant = {"_NP_V2" = true, "byteorder" = "little"}
+
 reportAny = false
 reportUnusedCallResult = false
 # because of `sys.version_info()` conditionals
 reportUnreachable = false
 # already handled by ruff F401
 reportUnusedImport = false
+# already handled by ruff F841
+reportUnusedVariable = false
 
 
 [tool.pytest.ini_options]
@@ -155,8 +172,6 @@ select = [
     "TCH",      # flake8-type-checking
     "ARG",      # flake8-unused-arguments
     "PTH",      # flake8-use-pathlib
-    "TD",       # flake8-todos
-    "FIX",      # flake8-fixme
     "ERA",      # eradicate
     "PD",       # pandas-vet
     "PGH",      # pygrep-hooks
@@ -180,6 +195,9 @@ ignore = [
     "PYI034",   # non-self-return-type
     "PYI036",   # bad-exit-annotation
 
+    # pylint
+    "PLR0904",  # too-many-public-methods
+
     # refurb
     "FURB118",  # reimplemented-operator
 ]
@@ -202,12 +220,11 @@ ignore = [
     "PLR2004",  # magic-value-comparison
 ]
 
-[tool.ruff.lint.isort]
-case-sensitive = true
-combine-as-imports = true
-known-first-party = ["optype"]
-lines-after-imports = 2
-lines-between-types = 0
+[tool.ruff.lint.flake8-import-conventions.extend-aliases]
+"ctypes" = "ct"
+"numpy.typing" = "npt"
+"optype.numpy" = "onp"
+
 
 [tool.ruff.lint.flake8-quotes]
 inline-quotes = "single"
@@ -215,12 +232,31 @@ inline-quotes = "single"
 [tool.ruff.lint.flake8-type-checking]
 strict = true
 
+[tool.ruff.lint.isort]
+case-sensitive = true
+combine-as-imports = true
+known-first-party = ["optype"]
+known-third-party = ["numpy", "pytest"]
+lines-after-imports = 2
+lines-between-types = 0
+
 [tool.ruff.lint.pylint]
 allow-dunder-method-names = [
     "__name__",     # attr of e.g. `type`, `function`, and `property` (py3.13+)
     "__qualname__", # attr like `__name__`
     "__replace__",  # method used by `copy.replace` in Python 3.13+
     "__self__",     # attr of a bound method
+
+    # numpy array special methods
+    "__array__",
+    "__array_finalize__",
+    "__array_function__",
+    "__array_priority__",
+    "__array_ufunc__",
+    "__array_wrap__",
+    # numpy array interface protocol V3
+    "__array_interface__",
+    "__array_struct__",
 ]
 
 [tool.ruff.format]
@@ -235,12 +271,11 @@ quote-style = "single"
 legacy_tox_ini = """
 [tox]
 isolated_build = true
-requires =
-    tox>=4
+requires = tox>=4
 envlist =
-    py{310,311,312,313b2}
-    pre-commit
     repo-review
+    pre-commit
+    py{310,311,312,313b2}
 
 [testenv]
 description = pytest


Test patch:
diff --git a/tests/helpers.py b/tests/helpers.py
index c103d695..3935bc4e 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -2,13 +2,13 @@
 
 import inspect
 import sys
-from typing import TYPE_CHECKING, cast
+from typing import TYPE_CHECKING, Any, cast, get_args as _get_args
 
 
 if sys.version_info >= (3, 13):
-    from typing import is_protocol
+    from typing import TypeAliasType, is_protocol
 else:
-    from typing_extensions import is_protocol
+    from typing_extensions import TypeAliasType, is_protocol
 
 
 if TYPE_CHECKING:
@@ -32,6 +32,20 @@ def is_runtime_protocol(cls: type, /) -> bool:
     return is_protocol(cls) and getattr(cls, '_is_runtime_protocol', False)
 
 
+def get_args(tp: TypeAliasType | type | str | Any, /) -> tuple[Any, ...]:
+    """
+    A less broken `typing.get_args()` that also works for type aliases defined
+    with the PEP695 `type` keyword, and recurses nested type aliases.
+    """
+    args = _get_args(tp.__value__ if isinstance(tp, TypeAliasType) else tp)
+    args_flat: list[Any] = []
+    for arg in args:
+        # recurse nested
+        args_flat.extend(get_args(arg) or [arg])
+
+    return tuple(args_flat)
+
+
 def get_protocol_members(cls: type, /) -> frozenset[str]:
     """
     A variant of `typing_extensions.get_protocol_members()` that doesn't
diff --git a/tests/numpy/__init__.py b/tests/numpy/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/numpy/test_array.py b/tests/numpy/test_array.py
new file mode 100644
index 00000000..cb1835ff
--- /dev/null
+++ b/tests/numpy/test_array.py
@@ -0,0 +1,141 @@
+# ruff: noqa: PYI042
+from typing import Any, TypeAlias, TypeVar
+
+import numpy as np
+import pytest
+
+import optype.numpy as onp
+
+
+_Shape0D: TypeAlias = tuple[()]
+_Shape1D: TypeAlias = tuple[int]
+_Shape2D: TypeAlias = tuple[int, int]
+
+
+# Don't wake up, Neo...
+@pytest.mark.filterwarnings('ignore:the matrix .*:PendingDeprecationWarning')
+def test_can_array():
+    sct: type[np.generic] = np.uint8
+
+    scalar: onp.CanArray[_Shape0D, sct] = sct(42)
+    assert isinstance(scalar, onp.CanArray)
+    assert not isinstance(42, onp.CanArray)
+
+    arr_0d: onp.CanArray[_Shape0D, sct] = np.array(42, sct)
+    assert isinstance(arr_0d, onp.CanArray)
+
+    arr_1d: onp.CanArray[_Shape1D, sct] = np.array([42], sct)
+    assert isinstance(arr_1d, onp.CanArray)
+    assert not isinstance([42], onp.CanArray)
+
+    arr_2d: onp.CanArray[_Shape2D, sct] = np.array([[42]], sct)
+    assert isinstance(arr_2d, onp.CanArray)
+
+    mat: onp.CanArray[_Shape2D, sct] = np.asmatrix(42, sct)
+    assert isinstance(mat, onp.CanArray)
+
+
+def test_some_array():
+    sct: type[np.generic] = np.uint8
+
+    py_sc: onp.AnyArray[_Shape0D, sct, int] = 42
+    np_sc: onp.AnyArray[_Shape0D, sct, int] = sct(py_sc)
+    assert np_sc.shape == ()
+
+    np_0d: onp.AnyArray[_Shape0D, sct, int] = np.array(py_sc, sct)
+    assert np_0d.shape == ()
+
+    py_1d: onp.AnyArray[_Shape1D, sct, int] = [42]
+    np_1d: onp.AnyArray[_Shape1D, sct, int] = np.array(py_1d, sct)
+    assert np_1d.shape == (1,)
+
+    py_2d: onp.AnyArray[_Shape2D, sct, int] = [[42]]
+    np_2d: onp.AnyArray[_Shape2D, sct, int] = np.array(py_2d, sct)
+    assert np_2d.shape == (1, 1)
+
+
+_T = TypeVar('_T', bound=np.generic)
+_Arr0D: TypeAlias = onp.Array[tuple[()], _T]
+_Arr1D: TypeAlias = onp.Array[tuple[int], _T]
+_Arr2D: TypeAlias = onp.Array[tuple[int, int], _T]
+
+
+def test_can_array_function():
+    sct: type[np.generic] = np.uint8
+
+    assert not isinstance(sct(42), onp.CanArrayFunction)
+
+    arr_0d: onp.CanArrayFunction[Any, _Arr0D[sct]] = np.array(42, sct)
+    assert isinstance(arr_0d, onp.CanArrayFunction)
+
+    arr_1d: onp.CanArrayFunction[Any, _Arr1D[sct]] = np.array([42], sct)
+    assert isinstance(arr_1d, onp.CanArrayFunction)
+
+    arr_2d: onp.CanArrayFunction[Any, _Arr2D[sct]] = np.array([[42]], sct)
+    assert isinstance(arr_2d, onp.CanArrayFunction)
+
+
+def test_can_array_finalize():
+    sct: type[np.generic] = np.uint8
+
+    arr_0d: onp.CanArrayFinalize[_Arr0D[sct]] = np.array(42, sct)
+    assert isinstance(arr_0d, onp.CanArrayFinalize)
+    assert not isinstance(42, onp.CanArrayFinalize)
+
+    arr_1d: onp.CanArrayFinalize[_Arr1D[sct]] = np.array([42], sct)
+    assert isinstance(arr_1d, onp.CanArrayFinalize)
+    assert not isinstance([42], onp.CanArrayFinalize)
+
+    arr_2d: onp.CanArrayFinalize[_Arr2D[sct]] = np.array([[42]], sct)
+    assert isinstance(arr_2d, onp.CanArrayFinalize)
+
+
+def test_can_array_wrap():
+    sct: type[np.generic] = np.uint8
+
+    arr_0d: onp.CanArrayWrap = np.array(42, sct)
+    assert isinstance(arr_0d, onp.CanArrayWrap)
+    assert not isinstance(42, onp.CanArrayWrap)
+
+    arr_1d: onp.CanArrayWrap = np.array([42], sct)
+    assert isinstance(arr_1d, onp.CanArrayWrap)
+    assert not isinstance([42], onp.CanArrayWrap)
+
+    arr_2d: onp.CanArrayWrap = np.array([[42]], sct)
+    assert isinstance(arr_2d, onp.CanArrayWrap)
+
+
+def test_has_array_priority():
+    sct: type[np.generic] = np.uint8
+
+    scalar: onp.HasArrayPriority = sct(42)
+    assert isinstance(scalar, onp.HasArrayPriority)
+
+    arr_0d: onp.HasArrayPriority = np.array(42, sct)
+    assert isinstance(arr_0d, onp.HasArrayPriority)
+    assert not isinstance(42, onp.HasArrayPriority)
+
+    arr_1d: onp.HasArrayPriority = np.array([42], sct)
+    assert isinstance(arr_1d, onp.HasArrayPriority)
+    assert not isinstance([42], onp.HasArrayPriority)
+
+    arr_2d: onp.HasArrayPriority = np.array([[42]], sct)
+    assert isinstance(arr_2d, onp.HasArrayPriority)
+
+
+def test_has_array_interface():
+    sct: type[np.generic] = np.uint8
+
+    scalar: onp.HasArrayInterface[Any] = sct(42)
+    assert isinstance(scalar, onp.HasArrayInterface)
+
+    arr_0d: onp.HasArrayInterface[Any] = np.array(42, sct)
+    assert isinstance(arr_0d, onp.HasArrayInterface)
+    assert not isinstance(42, onp.HasArrayInterface)
+
+    arr_1d: onp.HasArrayInterface[Any] = np.array([42], sct)
+    assert isinstance(arr_1d, onp.HasArrayInterface)
+    assert not isinstance([42], onp.HasArrayInterface)
+
+    arr_2d: onp.HasArrayInterface[Any] = np.array([[42]], sct)
+    assert isinstance(arr_2d, onp.HasArrayInterface)
diff --git a/tests/numpy/test_scalar.py b/tests/numpy/test_scalar.py
new file mode 100644
index 00000000..a9452fd6
--- /dev/null
+++ b/tests/numpy/test_scalar.py
@@ -0,0 +1,101 @@
+# ruff: noqa: F841
+import sys
+from typing import Any, Literal
+
+import numpy as np
+import pytest
+
+from optype.numpy import Scalar
+
+
+if sys.version_info >= (3, 13):
+    from typing import assert_type
+else:
+    from typing_extensions import assert_type
+
+
+_NP_V2 = np.__version__.startswith('2.')
+
+
+def test_scalar_from_bool():
+    x_py = True
+    x_np = np.True_
+
+    s_py: Scalar[bool] = x_py  # pyright: ignore[reportAssignmentType]
+    assert not isinstance(x_py, Scalar)
+
+    s_np_any_n: Scalar[Any] = x_np
+    s_np_any_1: Scalar[Any, Literal[1]] = x_np
+
+    s_np_n: Scalar[bool] = x_np
+    s_np_1: Scalar[bool, Literal[1]] = x_np
+
+    s_np_wrong_n: Scalar[str] = x_np  # pyright: ignore[reportAssignmentType]
+    s_np_wrong_1: Scalar[str, Literal[1]] = x_np  # pyright: ignore[reportAssignmentType]
+
+    assert isinstance(x_np, Scalar)
+
+    assert_type(x_np.item(), bool)
+    assert_type(s_np_n.item(), bool)
+
+
+@pytest.mark.parametrize(
+    'sctype',
+    [
+        np.int8, np.uint8,
+        np.int16, np.uint16,
+        np.int32, np.uint32,
+        np.int64, np.uint64,
+    ],
+)
+def test_scalar_from_integer(sctype: type[np.integer[Any]]):
+    x_py = 42
+    x_np = sctype(x_py)
+
+    s_py: Scalar[int] = x_py  # pyright: ignore[reportAssignmentType]
+    assert not isinstance(x_py, Scalar)
+
+    s_np: Scalar[int] = x_np
+    s_np_wrong: Scalar[str] = x_np  # pyright: ignore[reportAssignmentType]
+    s_np_wrong_contra: Scalar[bool] = x_np  # pyright: ignore[reportAssignmentType]
+    assert isinstance(x_np, Scalar)
+
+    y_py = s_np.item()
+    assert_type(y_py, int)
+    assert_type(y_py, bool)  # pyright: ignore[reportAssertTypeFailure]
+
+
+@pytest.mark.parametrize('sctype', [np.float16, np.float32, np.float64])
+def test_scalar_from_floating(sctype: type[np.floating[Any]]):
+    x_py = -1 / 12
+    x_np = sctype(x_py)
+
+    s_py: Scalar[float] = x_py  # pyright: ignore[reportAssignmentType]
+    assert not isinstance(x_py, Scalar)
+
+    s_np: Scalar[float] = x_np
+    s_np_wrong: Scalar[str] = x_np  # pyright: ignore[reportAssignmentType]
+    s_np_wrong_contra: Scalar[int] = x_np  # pyright: ignore[reportAssignmentType]
+    assert isinstance(x_np, Scalar)
+
+    y_py = s_np.item()
+    assert_type(y_py, float)
+    assert_type(y_py, int)  # pyright: ignore[reportAssertTypeFailure]
+
+
+@pytest.mark.parametrize('sctype', [np.complex64, np.complex128])
+def test_scalar_from_complex(sctype: type[np.complexfloating[Any, Any]]):
+    x_py = 3 - 4j
+    x_np = sctype(x_py)
+
+    s_py: Scalar[complex] = x_py  # pyright: ignore[reportAssignmentType]
+    assert not isinstance(x_py, Scalar)
+
+    s_np: Scalar[complex] = x_np
+    s_np_wrong: Scalar[str] = x_np  # pyright: ignore[reportAssignmentType]
+    s_np_wrong_contra: Scalar[float] = x_np  # pyright: ignore[reportAssignmentType]
+    assert isinstance(x_np, Scalar)
+
+    y_py = s_np.item()
+    assert_type(y_py, complex)
+    assert_type(y_py, float)  # pyright: ignore[reportAssertTypeFailure]
diff --git a/tests/numpy/test_sctypes.py b/tests/numpy/test_sctypes.py
new file mode 100644
index 00000000..c761bc51
--- /dev/null
+++ b/tests/numpy/test_sctypes.py
@@ -0,0 +1,79 @@
+# ruff: noqa: I001
+from typing import Any
+
+import numpy as np
+import pytest
+
+import optype.numpy as onp
+from optype.numpy import _sctype  # noqa: PLC2701  # pyright: ignore[reportPrivateUsage]
+from ..helpers import get_args  # noqa: TID252
+
+
+_TEMPORAL = 'Timedelta64', 'Datetime64'
+_FLEXIBLE = 'Str', 'Bytes', 'Void'
+_SIMPLE = 'Bool', 'Object'
+_NUMERIC_N = (
+    'UInt8', 'UInt16', 'UInt32', 'UInt64',
+    'Int8', 'Int16', 'Int32', 'Int64',
+    'Float16', 'Float32', 'Float64',
+    'Complex64', 'Complex128',
+)
+_NUMERIC_C = (
+    'UByte', 'UShort', 'ULong',
+    'Byte', 'Short', 'Long',
+    'Half', 'Single', 'Double',
+    'CSingle', 'CDouble',
+)
+
+
+def _get_attr_args(obj: Any, name: str) -> tuple[Any, ...]:
+    tp = getattr(obj, name)
+    return get_args(tp) or (tp, )
+
+
+def _get_dtype_info(name: str) -> tuple[
+    frozenset[type],
+    frozenset[str],
+    frozenset[str],
+]:
+    types = _get_attr_args(onp, f'Any{name}Value')
+    names = _get_attr_args(_sctype, f'_Any{name}Name')
+    chars = _get_attr_args(_sctype, f'_Any{name}Char')
+    return frozenset(types), frozenset(names), frozenset(chars)
+
+
+@pytest.mark.parametrize(
+    'name',
+    [*_NUMERIC_N, *_NUMERIC_C, *_SIMPLE, *_TEMPORAL, *_FLEXIBLE],
+)
+def test_sctypes(name: str):
+    dtype_expect = np.dtype(name.lower())
+    types, names, chars = _get_dtype_info(name)
+
+    assert dtype_expect.type in types
+
+    for arg in types | names | {c for c in chars if c[0] not in '=<>'}:
+        dtype = np.dtype(arg)
+        assert dtype == dtype_expect
+
+
+@pytest.mark.parametrize(
+    'name',
+    [*_NUMERIC_N, *_SIMPLE, *_TEMPORAL, *_FLEXIBLE],
+)
+def test_sctype_name(name: str):
+    dtype_expect = np.dtype(name.lower())
+    _, names, _ = _get_dtype_info(name)
+
+    assert dtype_expect.name in names
+
+
+@pytest.mark.parametrize(
+    'name',
+    [*_NUMERIC_C, *_SIMPLE, * _TEMPORAL, * _FLEXIBLE],
+)
+def test_sctype_char(name: str):
+    dtype_expect = np.dtype(name.lower())
+    _, _, chars = _get_dtype_info(name)
+
+    assert dtype_expect.char in chars
diff --git a/tests/numpy/test_ufunc.py b/tests/numpy/test_ufunc.py
new file mode 100644
index 00000000..c2f897d9
--- /dev/null
+++ b/tests/numpy/test_ufunc.py
@@ -0,0 +1,297 @@
+# ruff: noqa: F841, TCH002, TCH003, ERA001
+import math
+import sys
+from collections.abc import Callable as Fn
+from typing import Any, Literal, TypeAlias, TypeVar
+
+import numpy as np
+
+from optype.numpy import AnyUFunc, CanArrayUFunc
+
+
+if sys.version_info >= (3, 13):
+    from typing import LiteralString
+else:
+    from typing_extensions import LiteralString
+
+
+_ST__ScalarOrArray = TypeVar('_ST__ScalarOrArray', bound=np.generic)
+_ScalarOrArray = (
+    np.ndarray[Any, np.dtype[_ST__ScalarOrArray]]
+    | _ST__ScalarOrArray
+)
+
+_N0: TypeAlias = Literal[0]
+_N1: TypeAlias = Literal[1]
+_N2: TypeAlias = Literal[2]
+
+_Any2: TypeAlias = tuple[Any, Any]
+
+
+def test_anyufunc_ufunc_type():
+    tp: type[AnyUFunc] = np.ufunc
+
+    tp_1_any: type[AnyUFunc[Any]] = np.ufunc
+    tp_1_fn: type[AnyUFunc[Fn[..., Any]]] = np.ufunc
+
+    tp_2_any: type[AnyUFunc[Any, Any]] = np.ufunc
+    tp_2_int: type[AnyUFunc[Any, int]] = np.ufunc
+    # purposefully wrong
+    tp_2_one: type[AnyUFunc[Any, _N1]] = np.ufunc  # pyright: ignore[reportAssignmentType]
+
+    tp_3_any: type[AnyUFunc[Any, Any, Any]] = np.ufunc
+    tp_3_int: type[AnyUFunc[Any, Any, int]] = np.ufunc
+    # purposefully wrong
+    tp_3_one: type[AnyUFunc[Any, Any, _N1]] = np.ufunc  # pyright: ignore[reportAssignmentType]
+
+    tp_4_any: type[AnyUFunc[Any, Any, Any, Any]] = np.ufunc
+    tp_4_bound: type[AnyUFunc[Any, Any, Any, str | None]] = np.ufunc
+    # purposefully wrong
+    tp_4_none: type[AnyUFunc[Any, Any, Any, None]] = np.ufunc  # pyright: ignore[reportAssignmentType]
+    # purposefully wrong
+    tp_4_str: type[AnyUFunc[Any, Any, Any, str]] = np.ufunc  # pyright: ignore[reportAssignmentType]
+
+    assert isinstance(tp, AnyUFunc)
+
+
+def test_anyufunc_ufunc_11():
+    """Tests compatibility with `numpy._typing._func._UFunc_Nin1_Nout1`."""
+    fn = np.exp
+    assert isinstance(fn, np.ufunc)
+    assert fn.nin == 1
+    assert fn.nout == 1
+    assert fn.nargs == 2
+    assert fn.signature is None
+    assert fn.identity is None
+
+    # accepts either 1 or 2 positional-only arguments
+    fn_1: AnyUFunc[Fn[[Any], Any]] = fn
+    fn_2: AnyUFunc[Fn[[Any, Any], Any]] = fn
+
+    # all type params
+    fn_all: AnyUFunc[
+        Fn[..., _ScalarOrArray[np.inexact[Any] | np.object_]],  # __call__
+        _N1,  # nin
+        _N1,  # nout
+        None,  # signature
+        None,  # identity
+    ] = fn
+
+    # wrong signatures
+    fn_wrong_nargs_0: AnyUFunc[Fn[[], Any]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nargs_3: AnyUFunc[Fn[[Any, Any, Any], Any]] = fn  # pyright: ignore[reportAssignmentType]
+    # `_UFunc_Nin1_Nout1.__call__` return type is `Any`, i.e. useless.
+
+    # wrong nin/nout
+    fn_wrong_nin: AnyUFunc[Any, _N2] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nout: AnyUFunc[Any, Any, _N2] = fn  # pyright: ignore[reportAssignmentType]
+
+    # wrong (i.e. a defined) signature / identity
+    fn_wrong_sig: AnyUFunc[Any, Any, Any, str] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_id: AnyUFunc[Any, Any, Any, Any, float] = fn  # pyright: ignore[reportAssignmentType]
+
+    assert isinstance(fn, AnyUFunc)
+
+
+def test_anyufunc_ufunc_21():
+    """Tests compatibility with `numpy._typing._func._UFunc_Nin2_Nout1`."""
+    fn = np.add
+    assert isinstance(fn, np.ufunc)
+    assert fn.nin == 2
+    assert fn.nout == 1
+    assert fn.nargs == 3
+    assert fn.signature is None
+    assert fn.identity == 0
+
+    # accepts either 2 or 3 positional-only arguments
+    fn_2: AnyUFunc[Fn[[Any, Any], Any]] = fn
+    fn_3: AnyUFunc[Fn[[Any, Any, Any], Any]] = fn
+
+    # all type params
+    fn_all: AnyUFunc[
+        Fn[[Any, Any], _ScalarOrArray[np.generic]],  # __call__
+        _N2,  # nin
+        _N1,  # nout
+        None,  # signature
+        _N0,  # identity (incorrect in np2; consider `np.add('spam', 0)`; QED)
+    ] = fn
+
+    # wrong signatures
+    fn_wrong_nargs_0: AnyUFunc[Fn[[], Any]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nargs_4: AnyUFunc[Fn[[Any, Any, Any, Any], Any]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nargs_1: AnyUFunc[Fn[[Any], Any]] = fn  # pyright: ignore[reportAssignmentType]
+    # `_UFunc_Nin2_Nout1.__call__` return type is `Any`, i.e. useless.
+
+    # wrong nin/nout
+    fn_wrong_nin: AnyUFunc[Any, _N1] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nout: AnyUFunc[Any, Any, _N2] = fn  # pyright: ignore[reportAssignmentType]
+
+    # wrong (i.e. a defined) signature / identity
+    fn_wrong_sig: AnyUFunc[Any, Any, Any, str] = fn  # pyright: ignore[reportAssignmentType]
+    fn_missing_id: AnyUFunc[Any, Any, Any, Any, None] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_id: AnyUFunc[Any, Any, Any, Any, _N1] = fn  # pyright: ignore[reportAssignmentType]
+
+    assert isinstance(fn, AnyUFunc)
+
+
+def test_anyufunc_ufunc_12():
+    """Tests compatibility with `numpy._typing._func._UFunc_Nin1_Nout2`."""
+    fn = np.frexp
+    assert isinstance(fn, np.ufunc)
+    assert fn.nin == 1
+    assert fn.nout == 2
+    assert fn.nargs == 3
+    assert fn.signature is None
+    assert fn.identity is None
+
+    # accepts either 1, 2, or 3 positional-only arguments
+    fn_1: AnyUFunc[Fn[[Any], _Any2]] = fn
+    fn_2: AnyUFunc[Fn[[Any, Any], _Any2]] = fn
+    fn_3: AnyUFunc[Fn[[Any, Any, Any], _Any2]] = fn
+
+    # all type params
+    fn_all: AnyUFunc[Fn[..., _Any2], _N1, _N2, None, None] = fn
+
+    # wrong signatures
+    fn_wrong_nargs_0: AnyUFunc[Fn[[], _Any2]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nargs_4: AnyUFunc[Fn[[Any, Any, Any, Any], _Any2]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nret_1: AnyUFunc[Fn[..., tuple[Any]]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nret_3: AnyUFunc[Fn[..., tuple[Any, Any, Any]]] = fn  # pyright: ignore[reportAssignmentType]
+
+    # wrong nin/nout
+    fn_wrong_nin: AnyUFunc[Any, _N2] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nout: AnyUFunc[Any, Any, _N1] = fn  # pyright: ignore[reportAssignmentType]
+
+    # wrong (i.e. a defined) signature / identity
+    fn_wrong_sig: AnyUFunc[Any, Any, Any, str] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_id: AnyUFunc[Any, Any, Any, Any, float] = fn  # pyright: ignore[reportAssignmentType]
+
+    assert isinstance(fn, AnyUFunc)
+
+
+def test_anyufunc_ufunc22():
+    """Tests compatibility with `numpy._typing._func._UFunc_Nin2_Nout2`."""
+    fn = np.divmod
+    assert isinstance(fn, np.ufunc)
+    assert fn.nin == 2
+    assert fn.nout == 2
+    assert fn.nargs == 4
+    assert fn.signature is None
+    assert fn.identity is None
+
+    # accepts either 2, 3, or 4 positional-only arguments
+    fn_2: AnyUFunc[Fn[[Any, Any], _Any2]] = fn
+    fn_3: AnyUFunc[Fn[[Any, Any, Any], _Any2]] = fn
+    fn_4: AnyUFunc[Fn[[Any, Any, Any, Any], _Any2]] = fn
+
+    # all type params
+    fn_all: AnyUFunc[Fn[..., _Any2], _N2, _N2, None, None] = fn
+
+    # wrong signatures
+    fn_wrong_nargs_0: AnyUFunc[Fn[[], _Any2]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nargs_1: AnyUFunc[Fn[[Any], _Any2]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nargs_5: AnyUFunc[Fn[[Any, Any, Any, Any, Any], _Any2]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nret_1: AnyUFunc[Fn[..., tuple[Any]]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nret_3: AnyUFunc[Fn[..., tuple[Any, Any, Any]]] = fn  # pyright: ignore[reportAssignmentType]
+
+    # wrong nin/nout
+    fn_wrong_nin: AnyUFunc[Any, _N1] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nout: AnyUFunc[Any, Any, _N1] = fn  # pyright: ignore[reportAssignmentType]
+
+    # wrong (i.e. a defined) signature / identity
+    fn_wrong_sig: AnyUFunc[Any, Any, Any, str] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_id: AnyUFunc[Any, Any, Any, Any, float] = fn  # pyright: ignore[reportAssignmentType]
+
+    assert isinstance(fn, AnyUFunc)
+
+
+def test_anyufunc_gufunc21():
+    """Tests compatibility with `numpy._typing._func._GUFunc_Nin2_Nout1`."""
+    fn = np.matmul
+    assert fn.nin == 2
+    assert fn.nout == 1
+    assert fn.nargs == 3
+    assert fn.signature is not None
+    assert fn.identity is None
+
+    # accepts either 2 or 3 positional-only arguments
+    fn_2: AnyUFunc[Fn[[Any, Any], Any]] = fn
+    fn_3: AnyUFunc[Fn[[Any, Any, Any], Any]] = fn
+
+    # all type params
+    fn_all: AnyUFunc[
+        Fn[[Any, Any], _ScalarOrArray[np.generic]],  # __call__
+        _N2,  # nin
+        _N1,  # nout
+        LiteralString,  # signature
+        None,  # identity (why isn't this `1`?)
+    ] = fn
+
+    # wrong signatures
+    fn_wrong_nargs_0: AnyUFunc[Fn[[], Any]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nargs_1: AnyUFunc[Fn[[Any], Any]] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nargs_4: AnyUFunc[Fn[[Any, Any, Any, Any], Any]] = fn  # pyright: ignore[reportAssignmentType]
+    # `_UFunc_Nin2_Nout1.__call__` return type is `Any`, i.e. useless.
+
+    # wrong nin/nout
+    fn_wrong_nin: AnyUFunc[Any, _N1] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_nout: AnyUFunc[Any, Any, _N2] = fn  # pyright: ignore[reportAssignmentType]
+
+    # wrong (i.e. a defined) signature / identity
+    fn_wrong_sig: AnyUFunc[Any, Any, Any, None] = fn  # pyright: ignore[reportAssignmentType]
+    fn_wrong_id: AnyUFunc[Any, Any, Any, Any, _N1] = fn  # pyright: ignore[reportAssignmentType]
+
+    assert isinstance(fn, np.ufunc)
+    assert isinstance(fn, AnyUFunc)
+
+
+def beta_py(x: float, y: float, /) -> float:
+    """Beta function."""
+    return math.exp(math.lgamma(x) + math.lgamma(y) - math.lgamma(x + y))
+
+
+def test_anyufunc_custom_py():
+    # purposefully wrong
+    fn = beta_py
+    assert isinstance(fn, Fn)
+
+    not_a_ufunc: AnyUFunc = fn  # pyright: ignore[reportAssignmentType]
+
+    assert not isinstance(fn, np.ufunc)
+    assert not isinstance(fn, AnyUFunc)
+
+
+beta_np = np.frompyfunc(beta_py, nin=2, nout=1)
+
+
+def test_anyufunc_custom_np():
+    fn = beta_np
+    assert fn.nin == 2
+    assert fn.nout == 1
+    assert fn.nargs == 3
+    assert fn.signature is None
+    assert fn.identity is None
+
+    fn_any: AnyUFunc = fn
+
+    fn_1: AnyUFunc[Fn[[float, float], float]] = fn
+    # unfortunately, `frompyfunc` ignores the passed `nin` and `nout` types
+    # within the returned ufunc type (within numpy's own annotations)
+    # fn_3: AnyUFunc[Any, _N2, _N1] = beta_np
+    fn_3: AnyUFunc[Any, int, int] = fn
+
+    # similarly, the fact that `signature = None` isn't annotated correctly
+    # fn_4: AnyUFunc[Any, Any, Any, None] = beta_np
+    fn_4: AnyUFunc[Any, Any, Any, str | None] = fn
+
+    # the `identity` kwarg from `frompyfunc` is also ignored :(
+    # fn_4: AnyUFunc[Any, Any, Any, Any, None] = beta_np
+    fn_5: AnyUFunc[Any, Any, Any, Any, None] = fn
+
+    assert isinstance(fn, AnyUFunc)
+
+
+def test_canarrayufunc():
+    quantiles: CanArrayUFunc = np.linspace(0, 1, 100)
+    assert isinstance(quantiles, CanArrayUFunc)
+    assert not isinstance(list(quantiles), CanArrayUFunc)