Repository: scikit-hep/awkward

Issue:
ak.to_numpy turns an empty array of type string into an empty array of type float64
### Version of Awkward Array

HEAD

### Description and code to reproduce

```python
>>> array = ak.Array(["one", "two", "three"])

>>> ak.to_numpy(array)
array(['one', 'two', 'three'], dtype='<U5')

>>> array[0:0].type.show()
0 * string

>>> ak.to_numpy(array[0:0])
array([], dtype=float64)
```

Same for bytestrings.


Discussion:

Resolving patch:
diff --git a/src/awkward/contents/listarray.py b/src/awkward/contents/listarray.py
--- a/src/awkward/contents/listarray.py
+++ b/src/awkward/contents/listarray.py
@@ -1428,7 +1428,13 @@ def _to_arrow(self, pyarrow, mask_node, validbytes, length, options):
         )
 
     def _to_backend_array(self, allow_missing, backend):
-        return self.to_RegularArray()._to_backend_array(allow_missing, backend)
+        array_param = self.parameter("__array__")
+        if array_param in {"bytestring", "string"}:
+            # As our array-of-strings _may_ be empty, we should pass the dtype
+            dtype = np.str_ if array_param == "string" else np.bytes_
+            return backend.nplike.asarray(self.to_list(), dtype=dtype)
+        else:
+            return self.to_RegularArray()._to_backend_array(allow_missing, backend)
 
     def _remove_structure(self, backend, options):
         return self.to_ListOffsetArray64(False)._remove_structure(backend, options)
diff --git a/src/awkward/contents/listoffsetarray.py b/src/awkward/contents/listoffsetarray.py
--- a/src/awkward/contents/listoffsetarray.py
+++ b/src/awkward/contents/listoffsetarray.py
@@ -2004,9 +2004,11 @@ def _to_arrow(self, pyarrow, mask_node, validbytes, length, options):
     def _to_backend_array(self, allow_missing, backend):
         array_param = self.parameter("__array__")
         if array_param in {"bytestring", "string"}:
-            return backend.nplike.asarray(self.to_list())
-
-        return self.to_RegularArray()._to_backend_array(allow_missing, backend)
+            # As our array-of-strings _may_ be empty, we should pass the dtype
+            dtype = np.str_ if array_param == "string" else np.bytes_
+            return backend.nplike.asarray(self.to_list(), dtype=dtype)
+        else:
+            return self.to_RegularArray()._to_backend_array(allow_missing, backend)
 
     def _remove_structure(self, backend, options):
         if (
diff --git a/src/awkward/contents/regulararray.py b/src/awkward/contents/regulararray.py
--- a/src/awkward/contents/regulararray.py
+++ b/src/awkward/contents/regulararray.py
@@ -1206,19 +1206,27 @@ def _pad_none(self, target, axis, depth, clip):
     def _to_backend_array(self, allow_missing, backend):
         array_param = self.parameter("__array__")
         if array_param in {"bytestring", "string"}:
-            return backend.nplike.asarray(self.to_list())
-
-        out = self._content._to_backend_array(allow_missing, backend)
-        shape = (self._length, self._size) + out.shape[1:]
-
-        # ShapeItem is a defined type, but some nplikes don't map onto the entire space; e.g.
-        # NumPy never has `None` shape items. We require that if a shape-item is used between nplikes
-        # they both be the same "known-shape-ness".
-        assert self._backend.index_nplike.known_data == self._backend.nplike.known_data
-        return self._backend.nplike.reshape(
-            out[: self._backend.nplike.shape_item_as_index(self._length * self._size)],
-            shape,
-        )
+            # As our array-of-strings _may_ be empty, we should pass the dtype
+            dtype = np.str_ if array_param == "string" else np.bytes_
+            return backend.nplike.asarray(self.to_list(), dtype=dtype)
+        else:
+            out = self._content._to_backend_array(allow_missing, backend)
+            shape = (self._length, self._size) + out.shape[1:]
+
+            # ShapeItem is a defined type, but some nplikes don't map onto the entire space; e.g.
+            # NumPy never has `None` shape items. We require that if a shape-item is used between nplikes
+            # they both be the same "known-shape-ness".
+            assert (
+                self._backend.index_nplike.known_data == self._backend.nplike.known_data
+            )
+            return self._backend.nplike.reshape(
+                out[
+                    : self._backend.nplike.shape_item_as_index(
+                        self._length * self._size
+                    )
+                ],
+                shape,
+            )
 
     def _to_arrow(self, pyarrow, mask_node, validbytes, length, options):
         assert self._backend.nplike.known_data


Test patch:
diff --git a/tests/test_2364_empty_list_of_string.py b/tests/test_2364_empty_list_of_string.py
new file mode 100644
--- /dev/null
+++ b/tests/test_2364_empty_list_of_string.py
@@ -0,0 +1,33 @@
+# BSD 3-Clause License; see https://github.com/scikit-hep/awkward-1.0/blob/main/LICENSE
+
+import numpy as np
+
+import awkward as ak
+
+
+def test_non_empty_string():
+    array = ak.Array(["this", "that", "foo", "bar"])
+    result = ak.to_numpy(array)
+    assert result.dtype == np.dtype("U4")
+    assert result.tolist() == array.tolist()
+
+
+def test_non_empty_bytestring():
+    array = ak.Array([b"this", b"that", b"foo", b"bar"])
+    result = ak.to_numpy(array)
+    assert result.dtype == np.dtype("S4")
+    assert result.tolist() == array.tolist()
+
+
+def test_empty_string():
+    array = ak.Array(["this", "that", "foo", "bar"])
+    result = ak.to_numpy(array[:0])
+    assert result.dtype == np.dtype("U1")
+    assert result.tolist() == []
+
+
+def test_empty_bytestring():
+    array = ak.Array([b"this", b"that", b"foo", b"bar"])
+    result = ak.to_numpy(array[:0])
+    assert result.dtype == np.dtype("S1")
+    assert result.tolist() == []