Repository: narwhals-dev/narwhals
Pull request #2051 (2025-02-20 07:35:54)

chore(typing): enable typing checks for `pyspark`
<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [x] 🐳 Other

## Related issues

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below


dangotbanned: @EdAbati do we want this merged before or after #2038?
dangotbanned: I'd really like this PR to be resolving warnings vs just adding ignore comments.
I do understand we don't have stubs to work with, so @EdAbati let me know if this is an unreasonable ask
EdAbati: Thanks for the comments, I'll move  the `unpivot` fix out so we can concentrate on discussing typing here. I'll reply later 
EdAbati: Addressed some of the comments thanks :)

I will check again later today/tomorrow if there is anything more that can be fixed instead of being ignored.
FBruzzesi: > Regarding "do we want this merged before or after #2038?"
> 
> I guess it is up to @FBruzzesi. Maybe we want to enable type checking first in case we'd catch a sneaky bug? I think should be fine either way

I might have missed this thread and merged already 🥲
dangotbanned: https://github.com/narwhals-dev/narwhals/actions/runs/13527240491/job/37800780977?pr=2051

This one is gonna take a little extra work to fix

![image](https://github.com/user-attachments/assets/a6df58ce-60ba-4687-a737-842aa42fbbd1)



Patch:
diff --git a/.github/workflows/typing.yml b/.github/workflows/typing.yml
index 8df0cdf2..3264a538 100644
--- a/.github/workflows/typing.yml
+++ b/.github/workflows/typing.yml
@@ -32,7 +32,7 @@ jobs:
         # TODO: add more dependencies/backends incrementally
         run: |
           source .venv/bin/activate
-          uv pip install -e ".[tests, typing, core]"
+          uv pip install -e ".[tests, typing, core, pyspark, sqlframe]"
       - name: show-deps
         run: |
           source .venv/bin/activate
diff --git a/docs/api-reference/expr_str.md b/docs/api-reference/expr_str.md
index 01966a87..bb6abb7e 100644
--- a/docs/api-reference/expr_str.md
+++ b/docs/api-reference/expr_str.md
@@ -11,6 +11,7 @@
         - replace
         - replace_all
         - slice
+        - split
         - starts_with
         - strip_chars
         - tail
diff --git a/docs/api-reference/series_str.md b/docs/api-reference/series_str.md
index d4ca3602..42810161 100644
--- a/docs/api-reference/series_str.md
+++ b/docs/api-reference/series_str.md
@@ -11,6 +11,7 @@
         - replace
         - replace_all
         - slice
+        - split
         - starts_with
         - strip_chars
         - tail
diff --git a/narwhals/_arrow/expr_str.py b/narwhals/_arrow/expr_str.py
index 216022ba..11ba7591 100644
--- a/narwhals/_arrow/expr_str.py
+++ b/narwhals/_arrow/expr_str.py
@@ -67,6 +67,11 @@ class ArrowExprStringNamespace:
             self._compliant_expr, "str", "slice", offset=offset, length=length
         )
 
+    def split(self: Self, by: str) -> ArrowExpr:
+        return reuse_series_namespace_implementation(
+            self._compliant_expr, "str", "split", by=by
+        )
+
     def to_datetime(self: Self, format: str | None) -> ArrowExpr:  # noqa: A002
         return reuse_series_namespace_implementation(
             self._compliant_expr, "str", "to_datetime", format=format
diff --git a/narwhals/_arrow/series_str.py b/narwhals/_arrow/series_str.py
index 1de88a66..d44e54b2 100644
--- a/narwhals/_arrow/series_str.py
+++ b/narwhals/_arrow/series_str.py
@@ -72,6 +72,10 @@ class ArrowSeriesStringNamespace:
             )
         )
 
+    def split(self: Self, by: str) -> ArrowSeries:
+        split_series = pc.split_pattern(self._compliant_series._native_series, by)  # type: ignore[call-overload]
+        return self._compliant_series._from_native_series(split_series)
+
     def to_datetime(self: Self, format: str | None) -> ArrowSeries:  # noqa: A002
         native = self._compliant_series._native_series
         format = parse_datetime_format(native) if format is None else format
diff --git a/narwhals/_dask/expr_str.py b/narwhals/_dask/expr_str.py
index f22674b9..d30cca15 100644
--- a/narwhals/_dask/expr_str.py
+++ b/narwhals/_dask/expr_str.py
@@ -81,6 +81,13 @@ class DaskExprStringNamespace:
             length=length,
         )
 
+    def split(self: Self, by: str) -> DaskExpr:
+        return self._compliant_expr._from_call(
+            lambda _input, by: _input.str.split(pat=by),
+            "split",
+            by=by,
+        )
+
     def to_datetime(self: Self, format: str | None) -> DaskExpr:  # noqa: A002
         return self._compliant_expr._from_call(
             lambda _input, format: dd.to_datetime(_input, format=format),  # noqa: A006
diff --git a/narwhals/_duckdb/expr_str.py b/narwhals/_duckdb/expr_str.py
index 63bb829e..b7804662 100644
--- a/narwhals/_duckdb/expr_str.py
+++ b/narwhals/_duckdb/expr_str.py
@@ -54,6 +54,12 @@ class DuckDBExprStringNamespace:
 
         return self._compliant_expr._from_call(func, "slice")
 
+    def split(self: Self, by: str) -> DuckDBExpr:
+        return self._compliant_expr._from_call(
+            lambda _input: FunctionExpression("str_split", _input, lit(by)),
+            "split",
+        )
+
     def len_chars(self: Self) -> DuckDBExpr:
         return self._compliant_expr._from_call(
             lambda _input: FunctionExpression("length", _input), "len_chars"
diff --git a/narwhals/_pandas_like/expr_str.py b/narwhals/_pandas_like/expr_str.py
index da7cf610..8f241515 100644
--- a/narwhals/_pandas_like/expr_str.py
+++ b/narwhals/_pandas_like/expr_str.py
@@ -93,6 +93,11 @@ class PandasLikeExprStringNamespace:
             self._compliant_expr, "str", "slice", offset=offset, length=length
         )
 
+    def split(self: Self, by: str) -> PandasLikeExpr:
+        return reuse_series_namespace_implementation(
+            self._compliant_expr, "str", "split", by=by
+        )
+
     def to_datetime(self: Self, format: str | None) -> PandasLikeExpr:  # noqa: A002
         return reuse_series_namespace_implementation(
             self._compliant_expr,
diff --git a/narwhals/_pandas_like/series_str.py b/narwhals/_pandas_like/series_str.py
index 0cf480ee..5fae5867 100644
--- a/narwhals/_pandas_like/series_str.py
+++ b/narwhals/_pandas_like/series_str.py
@@ -2,7 +2,9 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
+from narwhals._pandas_like.utils import get_dtype_backend
 from narwhals._pandas_like.utils import to_datetime
+from narwhals.utils import Implementation
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -61,6 +63,27 @@ class PandasLikeSeriesStringNamespace:
             self._compliant_series._native_series.str.slice(start=offset, stop=stop),
         )
 
+    def split(self: Self, by: str) -> PandasLikeSeries:
+        if (
+            self._compliant_series._implementation is not Implementation.CUDF
+        ):  # pragma: no cover
+            dtype_backend = get_dtype_backend(
+                self._compliant_series._native_series.dtype,
+                self._compliant_series._implementation,
+            )
+            if dtype_backend != "pyarrow":
+                msg = (
+                    "This operation requires a pyarrow-backed series. "
+                    "Please refer to https://narwhals-dev.github.io/narwhals/api-reference/narwhals/#narwhals.maybe_convert_dtypes "
+                    "and ensure you are using dtype_backend='pyarrow'. "
+                    "Additionally, make sure you have pandas version 1.5+ and pyarrow installed. "
+                )
+                raise TypeError(msg)
+
+        return self._compliant_series._from_native_series(
+            self._compliant_series._native_series.str.split(pat=by),
+        )
+
     def to_datetime(self: Self, format: str | None) -> PandasLikeSeries:  # noqa: A002
         return self._compliant_series._from_native_series(
             to_datetime(self._compliant_series._implementation)(
diff --git a/narwhals/_spark_like/dataframe.py b/narwhals/_spark_like/dataframe.py
index 6198835d..be9c1006 100644
--- a/narwhals/_spark_like/dataframe.py
+++ b/narwhals/_spark_like/dataframe.py
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING
 from typing import Any
 from typing import Literal
 from typing import Sequence
+from typing import cast
 
 from narwhals._spark_like.utils import evaluate_exprs
 from narwhals._spark_like.utils import native_to_narwhals_dtype
@@ -26,7 +27,11 @@ if TYPE_CHECKING:
     import pyarrow as pa
     from pyspark.sql import Column
     from pyspark.sql import DataFrame
+    from pyspark.sql import Window
+    from pyspark.sql.session import SparkSession
+    from sqlframe.base.dataframe import BaseDataFrame as _SQLFrameDataFrame
     from typing_extensions import Self
+    from typing_extensions import TypeAlias
 
     from narwhals._spark_like.expr import SparkLikeExpr
     from narwhals._spark_like.group_by import SparkLikeLazyGroupBy
@@ -34,11 +39,17 @@ if TYPE_CHECKING:
     from narwhals.dtypes import DType
     from narwhals.utils import Version
 
+    SQLFrameDataFrame: TypeAlias = _SQLFrameDataFrame[Any, Any, Any, Any, Any]
+    _NativeDataFrame: TypeAlias = "DataFrame | SQLFrameDataFrame"
+
+Incomplete: TypeAlias = Any  # pragma: no cover
+"""Marker for working code that fails type checking."""
+
 
 class SparkLikeLazyFrame(CompliantLazyFrame):
     def __init__(
         self: Self,
-        native_dataframe: DataFrame,
+        native_dataframe: _NativeDataFrame,
         *,
         backend_version: tuple[int, ...],
         version: Version,
@@ -54,7 +65,11 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         validate_backend_version(self._implementation, self._backend_version)
 
     @property
-    def _F(self: Self) -> Any:  # noqa: N802
+    def _F(self: Self):  # type: ignore[no-untyped-def] # noqa: ANN202, N802
+        if TYPE_CHECKING:
+            from pyspark.sql import functions
+
+            return functions
         if self._implementation is Implementation.SQLFRAME:
             from sqlframe.base.session import _BaseSession
 
@@ -67,7 +82,12 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         return functions
 
     @property
-    def _native_dtypes(self: Self) -> Any:
+    def _native_dtypes(self: Self):  # type: ignore[no-untyped-def] # noqa: ANN202
+        if TYPE_CHECKING:
+            from pyspark.sql import types
+
+            return types
+
         if self._implementation is Implementation.SQLFRAME:
             from sqlframe.base.session import _BaseSession
 
@@ -80,7 +100,7 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         return types
 
     @property
-    def _Window(self: Self) -> Any:  # noqa: N802
+    def _Window(self: Self) -> type[Window]:  # noqa: N802
         if self._implementation is Implementation.SQLFRAME:
             from sqlframe.base.session import _BaseSession
 
@@ -94,11 +114,11 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         return Window
 
     @property
-    def _session(self: Self) -> Any:
+    def _session(self: Self) -> SparkSession:
         if self._implementation is Implementation.SQLFRAME:
-            return self._native_frame.session
+            return cast("SQLFrameDataFrame", self._native_frame).session
 
-        return self._native_frame.sparkSession
+        return cast("DataFrame", self._native_frame).sparkSession
 
     def __native_namespace__(self: Self) -> ModuleType:  # pragma: no cover
         return self._implementation.to_native_namespace()
@@ -137,10 +157,9 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
         ):
             import pyarrow as pa  # ignore-banned-import
 
+            native_frame = cast("DataFrame", self._native_frame)
             try:
-                native_pyarrow_frame = pa.Table.from_batches(
-                    self._native_frame._collect_as_arrow()
-                )
+                return pa.Table.from_batches(native_frame._collect_as_arrow())
             except ValueError as exc:
                 if "at least one RecordBatch" in str(exc):
                     # Empty dataframe
@@ -154,7 +173,7 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
                         try:
                             native_dtype = narwhals_to_native_dtype(value, self._version)
                         except Exception as exc:  # noqa: BLE001
-                            native_spark_dtype = self._native_frame.schema[key].dataType
+                            native_spark_dtype = native_frame.schema[key].dataType
                             # If we can't convert the type, just set it to `pa.null`, and warn.
                             # Avoid the warning if we're starting from PySpark's void type.
                             # We can avoid the check when we introduce `nw.Null` dtype.
@@ -168,14 +187,13 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
                             schema.append((key, pa.null()))
                         else:
                             schema.append((key, native_dtype))
-                    native_pyarrow_frame = pa.Table.from_pydict(
-                        data, schema=pa.schema(schema)
-                    )
+                    return pa.Table.from_pydict(data, schema=pa.schema(schema))
                 else:  # pragma: no cover
                     raise
         else:
-            native_pyarrow_frame = self._native_frame.toArrow()
-        return native_pyarrow_frame
+            # NOTE: See https://github.com/narwhals-dev/narwhals/pull/2051#discussion_r1969224309
+            to_arrow: Incomplete = self._native_frame.toArrow
+            return to_arrow()
 
     @property
     def columns(self: Self) -> list[str]:
@@ -246,10 +264,8 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
 
         if not new_columns:
             # return empty dataframe, like Polars does
-            spark_df = self._session.createDataFrame(
-                [], self._native_dtypes.StructType([])
-            )
-
+            schema = self._native_dtypes.StructType([])
+            spark_df = self._session.createDataFrame([], schema)
             return self._from_native_frame(spark_df)
 
         new_columns_list = [col.alias(col_name) for (col_name, col) in new_columns]
@@ -272,7 +288,8 @@ class SparkLikeLazyFrame(CompliantLazyFrame):
                 field.name: native_to_narwhals_dtype(
                     dtype=field.dataType,
                     version=self._version,
-                    spark_types=self._native_dtypes,
+                    # NOTE: Unclear if this is an unsafe hash (https://github.com/narwhals-dev/narwhals/pull/2051#discussion_r1970074662)
+                    spark_types=self._native_dtypes,  # pyright: ignore[reportArgumentType]
                 )
                 for field in self._native_frame.schema
             }
diff --git a/narwhals/_spark_like/expr.py b/narwhals/_spark_like/expr.py
index 0bc57253..41bbe46c 100644
--- a/narwhals/_spark_like/expr.py
+++ b/narwhals/_spark_like/expr.py
@@ -31,7 +31,7 @@ if TYPE_CHECKING:
     from narwhals.utils import Version
 
 
-class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):
+class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):  # type: ignore[type-var] # (#2044)
     _depth = 0  # Unused, just for compatibility with CompliantExpr
 
     def __init__(
@@ -301,7 +301,7 @@ class SparkLikeExpr(CompliantExpr["SparkLikeLazyFrame", "Column"]):
         )
 
     def __invert__(self: Self) -> Self:
-        invert = cast("Callable[..., SparkLikeExpr]", operator.invert)
+        invert = cast("Callable[..., Column]", operator.invert)
         return self._from_call(invert, "__invert__")
 
     def abs(self: Self) -> Self:
diff --git a/narwhals/_spark_like/expr_str.py b/narwhals/_spark_like/expr_str.py
index 13962b83..c84eefec 100644
--- a/narwhals/_spark_like/expr_str.py
+++ b/narwhals/_spark_like/expr_str.py
@@ -90,6 +90,12 @@ class SparkLikeExprStringNamespace:
 
         return self._compliant_expr._from_call(func, "slice")
 
+    def split(self: Self, by: str) -> SparkLikeExpr:
+        return self._compliant_expr._from_call(
+            lambda _input: self._compliant_expr._F.split(_input, by),
+            "split",
+        )
+
     def to_uppercase(self: Self) -> SparkLikeExpr:
         return self._compliant_expr._from_call(
             self._compliant_expr._F.upper, "to_uppercase"
diff --git a/narwhals/_spark_like/namespace.py b/narwhals/_spark_like/namespace.py
index 74d08414..07303aee 100644
--- a/narwhals/_spark_like/namespace.py
+++ b/narwhals/_spark_like/namespace.py
@@ -8,6 +8,7 @@ from typing import Callable
 from typing import Iterable
 from typing import Literal
 from typing import Sequence
+from typing import cast
 
 from narwhals._expression_parsing import combine_alias_output_names
 from narwhals._expression_parsing import combine_evaluate_output_names
@@ -29,7 +30,7 @@ if TYPE_CHECKING:
     from narwhals.utils import Version
 
 
-class SparkLikeNamespace(CompliantNamespace["SparkLikeLazyFrame", "Column"]):
+class SparkLikeNamespace(CompliantNamespace["SparkLikeLazyFrame", "Column"]):  # type: ignore[type-var] # (#2044)
     def __init__(
         self: Self,
         *,
@@ -222,7 +223,7 @@ class SparkLikeNamespace(CompliantNamespace["SparkLikeLazyFrame", "Column"]):
         *,
         how: Literal["horizontal", "vertical", "diagonal"],
     ) -> SparkLikeLazyFrame:
-        dfs: list[DataFrame] = [item._native_frame for item in items]
+        dfs = cast("Sequence[DataFrame]", [item._native_frame for item in items])
         if how == "horizontal":
             msg = (
                 "Horizontal concatenation is not supported for LazyFrame backed by "
diff --git a/narwhals/_spark_like/utils.py b/narwhals/_spark_like/utils.py
index fd68125b..29558c3b 100644
--- a/narwhals/_spark_like/utils.py
+++ b/narwhals/_spark_like/utils.py
@@ -11,55 +11,59 @@ if TYPE_CHECKING:
     from types import ModuleType
 
     import pyspark.sql.types as pyspark_types
+    import sqlframe.base.types as sqlframe_types
     from pyspark.sql import Column
+    from typing_extensions import TypeAlias
 
     from narwhals._spark_like.dataframe import SparkLikeLazyFrame
     from narwhals._spark_like.expr import SparkLikeExpr
     from narwhals.dtypes import DType
     from narwhals.utils import Version
 
+    _NativeDType: TypeAlias = "pyspark_types.DataType | sqlframe_types.DataType"
+
 
 # NOTE: don't lru_cache this as `ModuleType` isn't hashable
 def native_to_narwhals_dtype(
-    dtype: pyspark_types.DataType,
-    version: Version,
-    spark_types: ModuleType,
+    dtype: _NativeDType, version: Version, spark_types: ModuleType
 ) -> DType:  # pragma: no cover
     dtypes = import_dtypes_module(version=version)
+    if TYPE_CHECKING:
+        native = pyspark_types
+    else:
+        native = spark_types
 
-    if isinstance(dtype, spark_types.DoubleType):
+    if isinstance(dtype, native.DoubleType):
         return dtypes.Float64()
-    if isinstance(dtype, spark_types.FloatType):
+    if isinstance(dtype, native.FloatType):
         return dtypes.Float32()
-    if isinstance(dtype, spark_types.LongType):
+    if isinstance(dtype, native.LongType):
         return dtypes.Int64()
-    if isinstance(dtype, spark_types.IntegerType):
+    if isinstance(dtype, native.IntegerType):
         return dtypes.Int32()
-    if isinstance(dtype, spark_types.ShortType):
+    if isinstance(dtype, native.ShortType):
         return dtypes.Int16()
-    if isinstance(dtype, spark_types.ByteType):
+    if isinstance(dtype, native.ByteType):
         return dtypes.Int8()
-    if isinstance(
-        dtype, (spark_types.StringType, spark_types.VarcharType, spark_types.CharType)
-    ):
+    if isinstance(dtype, (native.StringType, native.VarcharType, native.CharType)):
         return dtypes.String()
-    if isinstance(dtype, spark_types.BooleanType):
+    if isinstance(dtype, native.BooleanType):
         return dtypes.Boolean()
-    if isinstance(dtype, spark_types.DateType):
+    if isinstance(dtype, native.DateType):
         return dtypes.Date()
-    if isinstance(dtype, spark_types.TimestampNTZType):
+    if isinstance(dtype, native.TimestampNTZType):
         return dtypes.Datetime()
-    if isinstance(dtype, spark_types.TimestampType):
+    if isinstance(dtype, native.TimestampType):
         return dtypes.Datetime(time_zone="UTC")
-    if isinstance(dtype, spark_types.DecimalType):
+    if isinstance(dtype, native.DecimalType):
         return dtypes.Decimal()
-    if isinstance(dtype, spark_types.ArrayType):
+    if isinstance(dtype, native.ArrayType):
         return dtypes.List(
             inner=native_to_narwhals_dtype(
                 dtype.elementType, version=version, spark_types=spark_types
             )
         )
-    if isinstance(dtype, spark_types.StructType):
+    if isinstance(dtype, native.StructType):
         return dtypes.Struct(
             fields=[
                 dtypes.Field(
@@ -78,48 +82,50 @@ def narwhals_to_native_dtype(
     dtype: DType | type[DType], version: Version, spark_types: ModuleType
 ) -> pyspark_types.DataType:
     dtypes = import_dtypes_module(version)
+    if TYPE_CHECKING:
+        native = pyspark_types
+    else:
+        native = spark_types
 
     if isinstance_or_issubclass(dtype, dtypes.Float64):
-        return spark_types.DoubleType()
+        return native.DoubleType()
     if isinstance_or_issubclass(dtype, dtypes.Float32):
-        return spark_types.FloatType()
+        return native.FloatType()
     if isinstance_or_issubclass(dtype, dtypes.Int64):
-        return spark_types.LongType()
+        return native.LongType()
     if isinstance_or_issubclass(dtype, dtypes.Int32):
-        return spark_types.IntegerType()
+        return native.IntegerType()
     if isinstance_or_issubclass(dtype, dtypes.Int16):
-        return spark_types.ShortType()
+        return native.ShortType()
     if isinstance_or_issubclass(dtype, dtypes.Int8):
-        return spark_types.ByteType()
+        return native.ByteType()
     if isinstance_or_issubclass(dtype, dtypes.String):
-        return spark_types.StringType()
+        return native.StringType()
     if isinstance_or_issubclass(dtype, dtypes.Boolean):
-        return spark_types.BooleanType()
+        return native.BooleanType()
     if isinstance_or_issubclass(dtype, dtypes.Date):
-        return spark_types.DateType()
+        return native.DateType()
     if isinstance_or_issubclass(dtype, dtypes.Datetime):
         dt_time_zone = dtype.time_zone
         if dt_time_zone is None:
-            return spark_types.TimestampNTZType()
+            return native.TimestampNTZType()
         if dt_time_zone != "UTC":  # pragma: no cover
             msg = f"Only UTC time zone is supported for PySpark, got: {dt_time_zone}"
             raise ValueError(msg)
-        return spark_types.TimestampType()
+        return native.TimestampType()
     if isinstance_or_issubclass(dtype, (dtypes.List, dtypes.Array)):
-        return spark_types.ArrayType(
+        return native.ArrayType(
             elementType=narwhals_to_native_dtype(
-                dtype.inner, version=version, spark_types=spark_types
+                dtype.inner, version=version, spark_types=native
             )
         )
     if isinstance_or_issubclass(dtype, dtypes.Struct):  # pragma: no cover
-        return spark_types.StructType(
+        return native.StructType(
             fields=[
-                spark_types.StructField(
+                native.StructField(
                     name=field.name,
                     dataType=narwhals_to_native_dtype(
-                        field.dtype,
-                        version=version,
-                        spark_types=spark_types,
+                        field.dtype, version=version, spark_types=native
                     ),
                 )
                 for field in dtype.fields
@@ -147,7 +153,7 @@ def narwhals_to_native_dtype(
 def evaluate_exprs(
     df: SparkLikeLazyFrame, /, *exprs: SparkLikeExpr
 ) -> list[tuple[str, Column]]:
-    native_results: list[tuple[str, list[Column]]] = []
+    native_results: list[tuple[str, Column]] = []
 
     for expr in exprs:
         native_series_list = expr._call(df)
diff --git a/narwhals/dependencies.py b/narwhals/dependencies.py
index 6d772597..071df0a1 100644
--- a/narwhals/dependencies.py
+++ b/narwhals/dependencies.py
@@ -18,11 +18,11 @@ if TYPE_CHECKING:
     import polars as pl
     import pyarrow as pa
     import pyspark.sql as pyspark_sql
-    import sqlframe
     from typing_extensions import TypeGuard
     from typing_extensions import TypeIs
 
     from narwhals._arrow.typing import ArrowChunkedArray
+    from narwhals._spark_like.dataframe import SQLFrameDataFrame
     from narwhals.dataframe import DataFrame
     from narwhals.dataframe import LazyFrame
     from narwhals.series import Series
@@ -231,7 +231,7 @@ def is_pyspark_dataframe(df: Any) -> TypeIs[pyspark_sql.DataFrame]:
     )
 
 
-def is_sqlframe_dataframe(df: Any) -> TypeIs[sqlframe.base.dataframe.BaseDataFrame]:
+def is_sqlframe_dataframe(df: Any) -> TypeIs[SQLFrameDataFrame]:
     """Check whether `df` is a SQLFrame DataFrame without importing SQLFrame."""
     return bool(
         (sqlframe := get_sqlframe()) is not None
diff --git a/narwhals/expr_str.py b/narwhals/expr_str.py
index cb4ddf6b..83b4e20e 100644
--- a/narwhals/expr_str.py
+++ b/narwhals/expr_str.py
@@ -271,6 +271,40 @@ class ExprStringNamespace(Generic[ExprT]):
             self._expr._metadata,
         )
 
+    def split(self: Self, by: str) -> ExprT:
+        r"""Split the string values of an expression by a substring.
+
+        Arguments:
+            by: Substring to split by.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> df_native = pl.DataFrame({"s": ["foo bar", "foo_bar"]})
+            >>> df = nw.from_native(df_native)
+            >>> df.with_columns(nw.col("s").str.split("_").alias("s_split"))
+            ┌────────────────────────────┐
+            |     Narwhals DataFrame     |
+            |----------------------------|
+            |shape: (2, 2)               |
+            |┌─────────┬────────────────┐|
+            |│ s       ┆ s_split        │|
+            |│ ---     ┆ ---            │|
+            |│ str     ┆ list[str]      │|
+            |╞═════════╪════════════════╡|
+            |│ foo bar ┆ ["foo bar"]    │|
+            |│ foo_bar ┆ ["foo", "bar"] │|
+            |└─────────┴────────────────┘|
+            └────────────────────────────┘
+        """
+        return self._expr.__class__(
+            lambda plx: self._expr._to_compliant_expr(plx).str.split(by=by),
+            self._expr._metadata,
+        )
+
     def head(self: Self, n: int = 5) -> ExprT:
         r"""Take the first n elements of each string.
 
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 9f64dba2..5e80e05c 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -9,8 +9,6 @@ from typing import Literal
 from typing import Mapping
 from typing import Protocol
 from typing import Sequence
-from typing import TypeVar
-from typing import Union
 from typing import overload
 
 from narwhals._expression_parsing import ExprKind
@@ -21,8 +19,6 @@ from narwhals._expression_parsing import combine_metadata
 from narwhals._expression_parsing import extract_compliant
 from narwhals._expression_parsing import infer_kind
 from narwhals._expression_parsing import is_scalar_like
-from narwhals.dataframe import DataFrame
-from narwhals.dataframe import LazyFrame
 from narwhals.dependencies import is_numpy_array
 from narwhals.dependencies import is_numpy_array_2d
 from narwhals.expr import Expr
@@ -39,12 +35,6 @@ from narwhals.utils import parse_version
 from narwhals.utils import validate_laziness
 from narwhals.utils import validate_native_namespace_and_backend
 
-# Missing type parameters for generic type "DataFrame"
-# However, trying to provide one results in mypy still complaining...
-# The rest of the annotations seem to work fine with this anyway
-FrameT = TypeVar("FrameT", bound=Union[DataFrame, LazyFrame])  # type: ignore[type-arg]
-
-
 if TYPE_CHECKING:
     from types import ModuleType
 
@@ -52,6 +42,8 @@ if TYPE_CHECKING:
     import pyarrow as pa
     from typing_extensions import Self
 
+    from narwhals.dataframe import DataFrame
+    from narwhals.dataframe import LazyFrame
     from narwhals.dtypes import DType
     from narwhals.series import Series
     from narwhals.typing import CompliantExpr
diff --git a/narwhals/series_str.py b/narwhals/series_str.py
index 873feda1..98e42807 100644
--- a/narwhals/series_str.py
+++ b/narwhals/series_str.py
@@ -236,6 +236,32 @@ class SeriesStringNamespace(Generic[SeriesT]):
             )
         )
 
+    def split(self: Self, by: str) -> SeriesT:
+        r"""Split the string values of a Series by a substring.
+
+        Arguments:
+            by: Substring to split by.
+
+        Returns:
+            A new Series containing lists of strings.
+
+        Examples:
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> s_native = pl.Series(["foo bar", "foo_bar"])
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.str.split("_").to_native()  # doctest: +NORMALIZE_WHITESPACE
+            shape: (2,)
+            Series: '' [list[str]]
+            [
+                    ["foo bar"]
+                    ["foo", "bar"]
+            ]
+        """
+        return self._narwhals_series._from_compliant_series(
+            self._narwhals_series._compliant_series.str.split(by=by)
+        )
+
     def head(self: Self, n: int = 5) -> SeriesT:
         r"""Take the first n elements of each string.
 
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index c43143a0..cececde6 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -7,7 +7,6 @@ from typing import Callable
 from typing import Iterable
 from typing import Literal
 from typing import Sequence
-from typing import TypeVar
 from typing import overload
 from warnings import warn
 
@@ -86,6 +85,7 @@ if TYPE_CHECKING:
     from typing import Mapping
 
     from typing_extensions import Self
+    from typing_extensions import TypeVar
 
     from narwhals.dtypes import DType
     from narwhals.functions import ArrowStreamExportable
@@ -95,7 +95,13 @@ if TYPE_CHECKING:
     from narwhals.typing import _1DArray
     from narwhals.typing import _2DArray
 
-T = TypeVar("T")
+    IntoSeriesT = TypeVar("IntoSeriesT", bound="IntoSeries", default=Any)
+    T = TypeVar("T", default=Any)
+else:
+    from typing import TypeVar
+
+    IntoSeriesT = TypeVar("IntoSeriesT", bound="IntoSeries")
+    T = TypeVar("T")
 
 
 class DataFrame(NwDataFrame[IntoDataFrameT]):
@@ -127,7 +133,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     # annotations are correct.
 
     @property
-    def _series(self: Self) -> type[Series]:
+    def _series(self: Self) -> type[Series[Any]]:
         return Series
 
     @property
@@ -138,7 +144,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     def __getitem__(  # type: ignore[overload-overlap]
         self: Self,
         item: str | tuple[slice | Sequence[int] | _1DArray, int | str],
-    ) -> Series: ...
+    ) -> Series[Any]: ...
     @overload
     def __getitem__(
         self: Self,
@@ -192,16 +198,18 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     # Not sure what mypy is complaining about, probably some fancy
     # thing that I need to understand category theory for
     @overload  # type: ignore[override]
-    def to_dict(self: Self, *, as_series: Literal[True] = ...) -> dict[str, Series]: ...
+    def to_dict(
+        self: Self, *, as_series: Literal[True] = ...
+    ) -> dict[str, Series[Any]]: ...
     @overload
     def to_dict(self: Self, *, as_series: Literal[False]) -> dict[str, list[Any]]: ...
     @overload
     def to_dict(
         self: Self, *, as_series: bool
-    ) -> dict[str, Series] | dict[str, list[Any]]: ...
+    ) -> dict[str, Series[Any]] | dict[str, list[Any]]: ...
     def to_dict(
         self: Self, *, as_series: bool = True
-    ) -> dict[str, Series] | dict[str, list[Any]]:
+    ) -> dict[str, Series[Any]] | dict[str, list[Any]]:
         """Convert DataFrame to a dictionary mapping column name to values.
 
         Arguments:
@@ -213,7 +221,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
         """
         return super().to_dict(as_series=as_series)  # type: ignore[return-value]
 
-    def is_duplicated(self: Self) -> Series:
+    def is_duplicated(self: Self) -> Series[Any]:
         r"""Get a mask of all duplicated rows in this DataFrame.
 
         Returns:
@@ -221,7 +229,7 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
         """
         return super().is_duplicated()  # type: ignore[return-value]
 
-    def is_unique(self: Self) -> Series:
+    def is_unique(self: Self) -> Series[Any]:
         r"""Get a mask of all unique rows in this DataFrame.
 
         Returns:
@@ -341,7 +349,7 @@ class LazyFrame(NwLazyFrame[IntoFrameT]):
         return super().tail(n)
 
 
-class Series(NwSeries[Any]):
+class Series(NwSeries[IntoSeriesT]):
     """Narwhals Series, backed by a native series.
 
     !!! warning
@@ -1102,7 +1110,7 @@ def _stableify(obj: NwDataFrame[IntoFrameT]) -> DataFrame[IntoFrameT]: ...
 @overload
 def _stableify(obj: NwLazyFrame[IntoFrameT]) -> LazyFrame[IntoFrameT]: ...
 @overload
-def _stableify(obj: NwSeries[Any]) -> Series: ...
+def _stableify(obj: NwSeries[IntoSeriesT]) -> Series[IntoSeriesT]: ...
 @overload
 def _stableify(obj: NwExpr) -> Expr: ...
 @overload
@@ -1110,8 +1118,12 @@ def _stableify(obj: Any) -> Any: ...
 
 
 def _stableify(
-    obj: NwDataFrame[IntoFrameT] | NwLazyFrame[IntoFrameT] | NwSeries[Any] | NwExpr | Any,
-) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series | Expr | Any:
+    obj: NwDataFrame[IntoFrameT]
+    | NwLazyFrame[IntoFrameT]
+    | NwSeries[IntoSeriesT]
+    | NwExpr
+    | Any,
+) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series[IntoSeriesT] | Expr | Any:
     if isinstance(obj, NwDataFrame):
         return DataFrame(
             obj._compliant_frame._change_version(Version.V1),
@@ -1134,26 +1146,26 @@ def _stableify(
 
 @overload
 def from_native(
-    native_object: IntoDataFrameT | IntoSeries,
+    native_object: IntoDataFrameT | IntoSeriesT,
     *,
     strict: Literal[False],
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[True],
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[IntoDataFrameT] | Series: ...
+) -> DataFrame[IntoDataFrameT] | Series[IntoSeriesT]: ...
 
 
 @overload
 def from_native(
-    native_object: IntoDataFrameT | IntoSeries,
+    native_object: IntoDataFrameT | IntoSeriesT,
     *,
     strict: Literal[False],
     eager_only: Literal[True],
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[IntoDataFrameT] | Series: ...
+) -> DataFrame[IntoDataFrameT] | Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1206,26 +1218,26 @@ def from_native(
 
 @overload
 def from_native(
-    native_object: IntoFrameT | IntoSeries,
+    native_object: IntoFrameT | IntoSeriesT,
     *,
     strict: Literal[False],
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series: ...
+) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series[IntoSeriesT]: ...
 
 
 @overload
 def from_native(
-    native_object: IntoSeries,
+    native_object: IntoSeriesT,
     *,
     strict: Literal[False],
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[True],
     allow_series: None = ...,
-) -> Series: ...
+) -> Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1285,19 +1297,19 @@ def from_native(
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[Any] | LazyFrame[Any] | Series: ...
+) -> DataFrame[Any] | LazyFrame[Any] | Series[Any]: ...
 
 
 @overload
 def from_native(
-    native_object: IntoSeries | Any,  # remain `Any` for downstream compatibility
+    native_object: IntoSeriesT,
     *,
     strict: Literal[True] = ...,
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[True],
     allow_series: None = ...,
-) -> Series: ...
+) -> Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1326,14 +1338,14 @@ def from_native(
 
 @overload
 def from_native(
-    native_object: IntoDataFrameT | IntoSeries,
+    native_object: IntoDataFrameT | IntoSeriesT,
     *,
     pass_through: Literal[True],
     eager_only: Literal[True],
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[IntoDataFrameT] | Series: ...
+) -> DataFrame[IntoDataFrameT] | Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1386,26 +1398,26 @@ def from_native(
 
 @overload
 def from_native(
-    native_object: IntoFrameT | IntoSeries,
+    native_object: IntoFrameT | IntoSeriesT,
     *,
     pass_through: Literal[True],
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series: ...
+) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series[IntoSeriesT]: ...
 
 
 @overload
 def from_native(
-    native_object: IntoSeries,
+    native_object: IntoSeriesT,
     *,
     pass_through: Literal[True],
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[True],
     allow_series: None = ...,
-) -> Series: ...
+) -> Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1465,19 +1477,19 @@ def from_native(
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[False] = ...,
     allow_series: Literal[True],
-) -> DataFrame[Any] | LazyFrame[Any] | Series: ...
+) -> DataFrame[Any] | LazyFrame[Any] | Series[Any]: ...
 
 
 @overload
 def from_native(
-    native_object: IntoSeries,
+    native_object: IntoSeriesT,
     *,
     pass_through: Literal[False] = ...,
     eager_only: Literal[False] = ...,
     eager_or_interchange_only: Literal[False] = ...,
     series_only: Literal[True],
     allow_series: None = ...,
-) -> Series: ...
+) -> Series[IntoSeriesT]: ...
 
 
 @overload
@@ -1506,7 +1518,7 @@ def from_native(
 
 
 def from_native(
-    native_object: IntoFrameT | IntoFrame | IntoSeries | T,
+    native_object: IntoFrameT | IntoFrame | IntoSeriesT | IntoSeries | T,
     *,
     strict: bool | None = None,
     pass_through: bool | None = None,
@@ -1514,7 +1526,7 @@ def from_native(
     eager_or_interchange_only: bool = False,
     series_only: bool = False,
     allow_series: bool | None = None,
-) -> LazyFrame[IntoFrameT] | DataFrame[IntoFrameT] | Series | T:
+) -> LazyFrame[IntoFrameT] | DataFrame[IntoFrameT] | Series[IntoSeriesT] | T:
     """Convert `native_object` to Narwhals Dataframe, Lazyframe, or Series.
 
     Arguments:
@@ -1595,7 +1607,9 @@ def to_native(
     narwhals_object: LazyFrame[IntoFrameT], *, strict: Literal[True] = ...
 ) -> IntoFrameT: ...
 @overload
-def to_native(narwhals_object: Series, *, strict: Literal[True] = ...) -> Any: ...
+def to_native(
+    narwhals_object: Series[IntoSeriesT], *, strict: Literal[True] = ...
+) -> IntoSeriesT: ...
 @overload
 def to_native(narwhals_object: Any, *, strict: bool) -> Any: ...
 @overload
@@ -1607,17 +1621,21 @@ def to_native(
     narwhals_object: LazyFrame[IntoFrameT], *, pass_through: Literal[False] = ...
 ) -> IntoFrameT: ...
 @overload
-def to_native(narwhals_object: Series, *, pass_through: Literal[False] = ...) -> Any: ...
+def to_native(
+    narwhals_object: Series[IntoSeriesT], *, pass_through: Literal[False] = ...
+) -> IntoSeriesT: ...
 @overload
 def to_native(narwhals_object: Any, *, pass_through: bool) -> Any: ...
 
 
 def to_native(
-    narwhals_object: DataFrame[IntoDataFrameT] | LazyFrame[IntoFrameT] | Series,
+    narwhals_object: DataFrame[IntoDataFrameT]
+    | LazyFrame[IntoFrameT]
+    | Series[IntoSeriesT],
     *,
     strict: bool | None = None,
     pass_through: bool | None = None,
-) -> IntoFrameT | Any:
+) -> IntoFrameT | IntoSeriesT | Any:
     """Convert Narwhals object to native one.
 
     Arguments:
@@ -2124,7 +2142,7 @@ def new_series(
     dtype: DType | type[DType] | None = None,
     *,
     native_namespace: ModuleType,
-) -> Series:
+) -> Series[Any]:
     """Instantiate Narwhals Series from iterable (e.g. list or array).
 
     Arguments:
diff --git a/narwhals/stable/v1/typing.py b/narwhals/stable/v1/typing.py
index fa7cd67f..94df953f 100644
--- a/narwhals/stable/v1/typing.py
+++ b/narwhals/stable/v1/typing.py
@@ -1,215 +1,215 @@
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-from typing import Any
-from typing import Protocol
-from typing import TypeVar
-from typing import Union
-
-if TYPE_CHECKING:
-    import sys
-
-    if sys.version_info >= (3, 10):
-        from typing import TypeAlias
-    else:
-        from typing_extensions import TypeAlias
-
-    from narwhals.stable.v1 import DataFrame
-    from narwhals.stable.v1 import Expr
-    from narwhals.stable.v1 import LazyFrame
-    from narwhals.stable.v1 import Series
-    from narwhals.stable.v1 import dtypes
-
-    # All dataframes supported by Narwhals have a
-    # `columns` property. Their similarities don't extend
-    # _that_ much further unfortunately...
-    class NativeFrame(Protocol):
-        @property
-        def columns(self) -> Any: ...
-
-        def join(self, *args: Any, **kwargs: Any) -> Any: ...
-
-    class NativeSeries(Protocol):
-        def __len__(self) -> int: ...
-
-    class DataFrameLike(Protocol):
-        def __dataframe__(self, *args: Any, **kwargs: Any) -> Any: ...
-
-
-IntoExpr: TypeAlias = Union["Expr", str, "Series"]
-"""Anything which can be converted to an expression.
-
-Use this to mean "either a Narwhals expression, or something
-which can be converted into one". For example, `exprs` in `DataFrame.select` is
-typed to accept `IntoExpr`, as it can either accept a `nw.Expr`
-(e.g. `df.select(nw.col('a'))`) or a string which will be interpreted as a
-`nw.Expr`, e.g. `df.select('a')`.
-"""
-
-IntoDataFrame: TypeAlias = Union["NativeFrame", "DataFrame[Any]", "DataFrameLike"]
-"""Anything which can be converted to a Narwhals DataFrame.
-
-Use this if your function accepts a narwhalifiable object but doesn't care about its backend.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoDataFrame
-    >>> def agnostic_shape(df_native: IntoDataFrame) -> tuple[int, int]:
-    ...     df = nw.from_native(df_native, eager_only=True)
-    ...     return df.shape
-"""
-
-IntoFrame: TypeAlias = Union[
-    "NativeFrame", "DataFrame[Any]", "LazyFrame[Any]", "DataFrameLike"
-]
-"""Anything which can be converted to a Narwhals DataFrame or LazyFrame.
-
-Use this if your function can accept an object which can be converted to either
-`nw.DataFrame` or `nw.LazyFrame` and it doesn't care about its backend.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoFrame
-    >>> def agnostic_columns(df_native: IntoFrame) -> list[str]:
-    ...     df = nw.from_native(df_native)
-    ...     return df.collect_schema().names()
-"""
-
-Frame: TypeAlias = Union["DataFrame[Any]", "LazyFrame[Any]"]
-"""Narwhals DataFrame or Narwhals LazyFrame.
-
-Use this if your function can work with either and your function doesn't care
-about its backend.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import Frame
-    >>> @nw.narwhalify
-    ... def agnostic_columns(df: Frame) -> list[str]:
-    ...     return df.columns
-"""
-
-IntoSeries: TypeAlias = Union["Series", "NativeSeries"]
-"""Anything which can be converted to a Narwhals Series.
-
-Use this if your function can accept an object which can be converted to `nw.Series`
-and it doesn't care about its backend.
-
-Examples:
-    >>> from typing import Any
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoSeries
-    >>> def agnostic_to_list(s_native: IntoSeries) -> list[Any]:
-    ...     s = nw.from_native(s_native)
-    ...     return s.to_list()
-"""
-
-IntoFrameT = TypeVar("IntoFrameT", bound="IntoFrame")
-"""TypeVar bound to object convertible to Narwhals DataFrame or Narwhals LazyFrame.
-
-Use this if your function accepts an object which is convertible to `nw.DataFrame`
-or `nw.LazyFrame` and returns an object of the same type.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoFrameT
-    >>> def agnostic_func(df_native: IntoFrameT) -> IntoFrameT:
-    ...     df = nw.from_native(df_native)
-    ...     return df.with_columns(c=nw.col("a") + 1).to_native()
-"""
-
-IntoDataFrameT = TypeVar("IntoDataFrameT", bound="IntoDataFrame")
-"""TypeVar bound to object convertible to Narwhals DataFrame.
-
-Use this if your function accepts an object which can be converted to `nw.DataFrame`
-and returns an object of the same class.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoDataFrameT
-    >>> def agnostic_func(df_native: IntoDataFrameT) -> IntoDataFrameT:
-    ...     df = nw.from_native(df_native, eager_only=True)
-    ...     return df.with_columns(c=df["a"] + 1).to_native()
-"""
-
-FrameT = TypeVar("FrameT", bound="Frame")
-"""TypeVar bound to Narwhals DataFrame or Narwhals LazyFrame.
-
-Use this if your function accepts either `nw.DataFrame` or `nw.LazyFrame` and returns
-an object of the same kind.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import FrameT
-    >>> @nw.narwhalify
-    ... def agnostic_func(df: FrameT) -> FrameT:
-    ...     return df.with_columns(c=nw.col("a") + 1)
-"""
-
-DataFrameT = TypeVar("DataFrameT", bound="DataFrame[Any]")
-"""TypeVar bound to Narwhals DataFrame.
-
-Use this if your function can accept a Narwhals DataFrame and returns a Narwhals
-DataFrame backed by the same backend.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import DataFrameT
-    >>> @nw.narwhalify
-    >>> def func(df: DataFrameT) -> DataFrameT:
-    ...     return df.with_columns(c=df["a"] + 1)
-"""
-
-IntoSeriesT = TypeVar("IntoSeriesT", bound="IntoSeries")
-"""TypeVar bound to object convertible to Narwhals Series.
-
-Use this if your function accepts an object  which can be converted to `nw.Series`
-and returns an object of the same class.
-
-Examples:
-    >>> import narwhals as nw
-    >>> from narwhals.typing import IntoSeriesT
-    >>> def agnostic_abs(s_native: IntoSeriesT) -> IntoSeriesT:
-    ...     s = nw.from_native(s_native, series_only=True)
-    ...     return s.abs().to_native()
-"""
-
-
-class DTypes:
-    Int64: type[dtypes.Int64]
-    Int32: type[dtypes.Int32]
-    Int16: type[dtypes.Int16]
-    Int8: type[dtypes.Int8]
-    UInt64: type[dtypes.UInt64]
-    UInt32: type[dtypes.UInt32]
-    UInt16: type[dtypes.UInt16]
-    UInt8: type[dtypes.UInt8]
-    Float64: type[dtypes.Float64]
-    Float32: type[dtypes.Float32]
-    String: type[dtypes.String]
-    Boolean: type[dtypes.Boolean]
-    Object: type[dtypes.Object]
-    Categorical: type[dtypes.Categorical]
-    Enum: type[dtypes.Enum]
-    Datetime: type[dtypes.Datetime]
-    Duration: type[dtypes.Duration]
-    Date: type[dtypes.Date]
-    Field: type[dtypes.Field]
-    Struct: type[dtypes.Struct]
-    List: type[dtypes.List]
-    Array: type[dtypes.Array]
-    Unknown: type[dtypes.Unknown]
-
-
-__all__ = [
-    "DataFrameT",
-    "Frame",
-    "FrameT",
-    "IntoDataFrame",
-    "IntoDataFrameT",
-    "IntoExpr",
-    "IntoFrame",
-    "IntoFrameT",
-    "IntoSeries",
-    "IntoSeriesT",
-]
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Protocol
+from typing import TypeVar
+from typing import Union
+
+if TYPE_CHECKING:
+    import sys
+
+    if sys.version_info >= (3, 10):
+        from typing import TypeAlias
+    else:
+        from typing_extensions import TypeAlias
+
+    from narwhals.stable.v1 import DataFrame
+    from narwhals.stable.v1 import Expr
+    from narwhals.stable.v1 import LazyFrame
+    from narwhals.stable.v1 import Series
+    from narwhals.stable.v1 import dtypes
+
+    # All dataframes supported by Narwhals have a
+    # `columns` property. Their similarities don't extend
+    # _that_ much further unfortunately...
+    class NativeFrame(Protocol):
+        @property
+        def columns(self) -> Any: ...
+
+        def join(self, *args: Any, **kwargs: Any) -> Any: ...
+
+    class NativeSeries(Protocol):
+        def __len__(self) -> int: ...
+
+    class DataFrameLike(Protocol):
+        def __dataframe__(self, *args: Any, **kwargs: Any) -> Any: ...
+
+
+IntoExpr: TypeAlias = Union["Expr", str, "Series[Any]"]
+"""Anything which can be converted to an expression.
+
+Use this to mean "either a Narwhals expression, or something
+which can be converted into one". For example, `exprs` in `DataFrame.select` is
+typed to accept `IntoExpr`, as it can either accept a `nw.Expr`
+(e.g. `df.select(nw.col('a'))`) or a string which will be interpreted as a
+`nw.Expr`, e.g. `df.select('a')`.
+"""
+
+IntoDataFrame: TypeAlias = Union["NativeFrame", "DataFrame[Any]", "DataFrameLike"]
+"""Anything which can be converted to a Narwhals DataFrame.
+
+Use this if your function accepts a narwhalifiable object but doesn't care about its backend.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoDataFrame
+    >>> def agnostic_shape(df_native: IntoDataFrame) -> tuple[int, int]:
+    ...     df = nw.from_native(df_native, eager_only=True)
+    ...     return df.shape
+"""
+
+IntoFrame: TypeAlias = Union[
+    "NativeFrame", "DataFrame[Any]", "LazyFrame[Any]", "DataFrameLike"
+]
+"""Anything which can be converted to a Narwhals DataFrame or LazyFrame.
+
+Use this if your function can accept an object which can be converted to either
+`nw.DataFrame` or `nw.LazyFrame` and it doesn't care about its backend.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoFrame
+    >>> def agnostic_columns(df_native: IntoFrame) -> list[str]:
+    ...     df = nw.from_native(df_native)
+    ...     return df.collect_schema().names()
+"""
+
+Frame: TypeAlias = Union["DataFrame[Any]", "LazyFrame[Any]"]
+"""Narwhals DataFrame or Narwhals LazyFrame.
+
+Use this if your function can work with either and your function doesn't care
+about its backend.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import Frame
+    >>> @nw.narwhalify
+    ... def agnostic_columns(df: Frame) -> list[str]:
+    ...     return df.columns
+"""
+
+IntoSeries: TypeAlias = Union["Series[Any]", "NativeSeries"]
+"""Anything which can be converted to a Narwhals Series.
+
+Use this if your function can accept an object which can be converted to `nw.Series`
+and it doesn't care about its backend.
+
+Examples:
+    >>> from typing import Any
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoSeries
+    >>> def agnostic_to_list(s_native: IntoSeries) -> list[Any]:
+    ...     s = nw.from_native(s_native)
+    ...     return s.to_list()
+"""
+
+IntoFrameT = TypeVar("IntoFrameT", bound="IntoFrame")
+"""TypeVar bound to object convertible to Narwhals DataFrame or Narwhals LazyFrame.
+
+Use this if your function accepts an object which is convertible to `nw.DataFrame`
+or `nw.LazyFrame` and returns an object of the same type.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoFrameT
+    >>> def agnostic_func(df_native: IntoFrameT) -> IntoFrameT:
+    ...     df = nw.from_native(df_native)
+    ...     return df.with_columns(c=nw.col("a") + 1).to_native()
+"""
+
+IntoDataFrameT = TypeVar("IntoDataFrameT", bound="IntoDataFrame")
+"""TypeVar bound to object convertible to Narwhals DataFrame.
+
+Use this if your function accepts an object which can be converted to `nw.DataFrame`
+and returns an object of the same class.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoDataFrameT
+    >>> def agnostic_func(df_native: IntoDataFrameT) -> IntoDataFrameT:
+    ...     df = nw.from_native(df_native, eager_only=True)
+    ...     return df.with_columns(c=df["a"] + 1).to_native()
+"""
+
+FrameT = TypeVar("FrameT", bound="Frame")
+"""TypeVar bound to Narwhals DataFrame or Narwhals LazyFrame.
+
+Use this if your function accepts either `nw.DataFrame` or `nw.LazyFrame` and returns
+an object of the same kind.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import FrameT
+    >>> @nw.narwhalify
+    ... def agnostic_func(df: FrameT) -> FrameT:
+    ...     return df.with_columns(c=nw.col("a") + 1)
+"""
+
+DataFrameT = TypeVar("DataFrameT", bound="DataFrame[Any]")
+"""TypeVar bound to Narwhals DataFrame.
+
+Use this if your function can accept a Narwhals DataFrame and returns a Narwhals
+DataFrame backed by the same backend.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import DataFrameT
+    >>> @nw.narwhalify
+    >>> def func(df: DataFrameT) -> DataFrameT:
+    ...     return df.with_columns(c=df["a"] + 1)
+"""
+
+IntoSeriesT = TypeVar("IntoSeriesT", bound="IntoSeries")
+"""TypeVar bound to object convertible to Narwhals Series.
+
+Use this if your function accepts an object  which can be converted to `nw.Series`
+and returns an object of the same class.
+
+Examples:
+    >>> import narwhals as nw
+    >>> from narwhals.typing import IntoSeriesT
+    >>> def agnostic_abs(s_native: IntoSeriesT) -> IntoSeriesT:
+    ...     s = nw.from_native(s_native, series_only=True)
+    ...     return s.abs().to_native()
+"""
+
+
+class DTypes:
+    Int64: type[dtypes.Int64]
+    Int32: type[dtypes.Int32]
+    Int16: type[dtypes.Int16]
+    Int8: type[dtypes.Int8]
+    UInt64: type[dtypes.UInt64]
+    UInt32: type[dtypes.UInt32]
+    UInt16: type[dtypes.UInt16]
+    UInt8: type[dtypes.UInt8]
+    Float64: type[dtypes.Float64]
+    Float32: type[dtypes.Float32]
+    String: type[dtypes.String]
+    Boolean: type[dtypes.Boolean]
+    Object: type[dtypes.Object]
+    Categorical: type[dtypes.Categorical]
+    Enum: type[dtypes.Enum]
+    Datetime: type[dtypes.Datetime]
+    Duration: type[dtypes.Duration]
+    Date: type[dtypes.Date]
+    Field: type[dtypes.Field]
+    Struct: type[dtypes.Struct]
+    List: type[dtypes.List]
+    Array: type[dtypes.Array]
+    Unknown: type[dtypes.Unknown]
+
+
+__all__ = [
+    "DataFrameT",
+    "Frame",
+    "FrameT",
+    "IntoDataFrame",
+    "IntoDataFrameT",
+    "IntoExpr",
+    "IntoFrame",
+    "IntoFrameT",
+    "IntoSeries",
+    "IntoSeriesT",
+]
diff --git a/pyproject.toml b/pyproject.toml
index 8a5dc190..7c3576d4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -254,7 +254,6 @@ module = [
   "ibis.*",
   "modin.*",
   "numpy.*",
-  "pyspark.*",
   "sklearn.*",
   "sqlframe.*",
 ]
@@ -270,6 +269,7 @@ module = [
   "*._ibis.*",
   "*._arrow.*",
   "*._dask.*",
+  "*._spark_like.*",
 ]
 warn_return_any = false
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index a0cde090..bf5d97b6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -154,7 +154,7 @@ def pyspark_lazy_constructor() -> Callable[[Any], IntoFrame]:  # pragma: no cove
         )
 
         session = (
-            SparkSession.builder.appName("unit-tests")
+            SparkSession.builder.appName("unit-tests")  # pyright: ignore[reportAttributeAccessIssue]
             .master("local[1]")
             .config("spark.ui.enabled", "false")
             # executing one task at a time makes the tests faster
@@ -172,7 +172,7 @@ def pyspark_lazy_constructor() -> Callable[[Any], IntoFrame]:  # pragma: no cove
             index_col_name = generate_temporary_column_name(n_bytes=8, columns=list(_obj))
             _obj[index_col_name] = list(range(len(_obj[next(iter(_obj))])))
 
-            return (  # type: ignore[no-any-return]
+            return (
                 session.createDataFrame([*zip(*_obj.values())], schema=[*_obj.keys()])
                 .repartition(2)
                 .orderBy(index_col_name)
diff --git a/tests/expr_and_series/dt/timestamp_test.py b/tests/expr_and_series/dt/timestamp_test.py
index 448091ca..ed22a16d 100644
--- a/tests/expr_and_series/dt/timestamp_test.py
+++ b/tests/expr_and_series/dt/timestamp_test.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from datetime import datetime
+from typing import TYPE_CHECKING
 from typing import Literal
 
 import hypothesis.strategies as st
@@ -18,6 +19,9 @@ from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 from tests.utils import is_windows
 
+if TYPE_CHECKING:
+    from narwhals.typing import IntoSeriesT
+
 data = {
     "a": [
         datetime(2021, 3, 1, 12, 34, 56, 49000),
@@ -221,7 +225,7 @@ def test_timestamp_hypothesis(
     import polars as pl
 
     @nw.narwhalify
-    def func(s: nw.Series) -> nw.Series:
+    def func(s: nw.Series[IntoSeriesT]) -> nw.Series[IntoSeriesT]:
         return s.dt.timestamp(time_unit)
 
     result_pl = func(pl.Series([inputs], dtype=pl.Datetime(starting_time_unit)))
diff --git a/tests/expr_and_series/str/split_test.py b/tests/expr_and_series/str/split_test.py
new file mode 100644
index 00000000..13e2c371
--- /dev/null
+++ b/tests/expr_and_series/str/split_test.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import Constructor
+from tests.utils import ConstructorEager
+from tests.utils import assert_equal_data
+
+data = {"s": ["foo bar", "foo_bar", "foo_bar_baz", "foo,bar"]}
+
+
+@pytest.mark.parametrize(
+    ("by", "expected"),
+    [
+        ("_", {"s": [["foo bar"], ["foo", "bar"], ["foo", "bar", "baz"], ["foo,bar"]]}),
+        (
+            ",",
+            {"s": [["foo bar"], ["foo_bar"], ["foo_bar_baz"], ["foo", "bar"]]},
+        ),
+    ],
+)
+def test_str_split(
+    constructor: Constructor,
+    by: str,
+    expected: Any,
+) -> None:
+    if "cudf" not in str(constructor) and (
+        constructor.__name__.startswith("pandas")
+        and "pyarrow" not in constructor.__name__
+    ):
+        df = nw.from_native(constructor(data))
+        msg = re.escape("This operation requires a pyarrow-backed series. ")
+        with pytest.raises(TypeError, match=msg):
+            df.select(nw.col("s").str.split(by=by))
+        return
+    df = nw.from_native(constructor(data))
+    result_frame = df.select(nw.col("s").str.split(by=by))
+    assert_equal_data(result_frame, expected)
+
+
+@pytest.mark.parametrize(
+    ("by", "expected"),
+    [
+        ("_", {"s": [["foo bar"], ["foo", "bar"], ["foo", "bar", "baz"], ["foo,bar"]]}),
+        (
+            ",",
+            {"s": [["foo bar"], ["foo_bar"], ["foo_bar_baz"], ["foo", "bar"]]},
+        ),
+    ],
+)
+def test_str_split_series(
+    constructor_eager: ConstructorEager,
+    by: str,
+    expected: Any,
+) -> None:
+    if "cudf" not in str(constructor_eager) and (
+        constructor_eager.__name__.startswith("pandas")
+        and "pyarrow" not in constructor_eager.__name__
+    ):
+        df = nw.from_native(constructor_eager(data), eager_only=True)
+        msg = re.escape("This operation requires a pyarrow-backed series. ")
+        with pytest.raises(TypeError, match=msg):
+            df["s"].str.split(by=by)
+        return
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    result_series = df["s"].str.split(by=by)
+    assert_equal_data({"s": result_series}, expected)
diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py
index 9ad0ec5b..7b48c8a7 100644
--- a/tests/translate/from_native_test.py
+++ b/tests/translate/from_native_test.py
@@ -3,6 +3,9 @@ from __future__ import annotations
 from contextlib import nullcontext as does_not_raise
 from typing import TYPE_CHECKING
 from typing import Any
+from typing import Iterable
+from typing import Literal
+from typing import cast
 
 import numpy as np
 import pandas as pd
@@ -241,7 +244,7 @@ def test_series_only_sqlframe() -> None:  # pragma: no cover
     df = session.createDataFrame([*zip(*data.values())], schema=[*data.keys()])
 
     with pytest.raises(TypeError, match="Cannot only use `series_only`"):
-        nw.from_native(df, series_only=True)
+        nw.from_native(df, series_only=True)  # pyright: ignore[reportArgumentType, reportCallIssue]
 
 
 @pytest.mark.parametrize(
@@ -287,3 +290,18 @@ def test_from_mock_interchange_protocol_non_strict() -> None:
     mockdf = MockDf()
     result = nw.from_native(mockdf, eager_only=True, strict=False)
     assert result is mockdf
+
+
+def test_from_native_altair_array_like() -> None:
+    obj: list[int] = [1, 2, 3, 4]
+    array_like = cast("Iterable[Any]", obj)
+    not_array_like: Literal[1] = 1
+
+    with pytest.raises(TypeError, match="got.+list"):
+        false_positive_native_series = nw.from_native(obj, series_only=True)  # noqa: F841
+
+    with pytest.raises(TypeError, match="got.+list"):
+        true_negative_iterable = nw.from_native(array_like, series_only=True)  # type: ignore[call-overload] # noqa: F841
+
+    with pytest.raises(TypeError, match="got.+int"):
+        true_negative_not_native_series = nw.from_native(not_array_like, series_only=True)  # type: ignore[call-overload] # noqa: F841
diff --git a/tests/utils.py b/tests/utils.py
index 65633e85..dc626df8 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -131,6 +131,10 @@ def assert_equal_data(result: Any, expected: Mapping[str, Any]) -> None:
                 are_equivalent_values = lhs is None or math.isnan(lhs)
             elif lhs is None:
                 are_equivalent_values = rhs is None
+            elif isinstance(lhs, list) and isinstance(rhs, list):
+                are_equivalent_values = all(
+                    left_side == right_side for left_side, right_side in zip(lhs, rhs)
+                )
             elif pd.isna(lhs):
                 are_equivalent_values = pd.isna(rhs)
             else: