Repository: narwhals-dev/narwhals
Pull request #2663 (2025-06-10 20:55:05)

feat: when-then broadcasting
<!--
# 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
- [x] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #2645 

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below

This adds broadcasting behavior to the `when/then` pattern similar to Polars (see #2645) for our eager backends and dask.
The implementation for the Lazy backends will require #2652 unless we want to introduce some hacky workarounds (e.g. we may be able to do this by joining after an aggregation to "broadcast" results; however I haven't fully thought through how this would look in the codebase).

camriddell: Rebased on top of #2662 do not merge until that PR lands in main.
We should be able to merge once that one receives the green light (provided all tests pass here).

Patch:
diff --git a/docs/api-reference/expr.md b/docs/api-reference/expr.md
index 02e4e02a..834497a3 100644
--- a/docs/api-reference/expr.md
+++ b/docs/api-reference/expr.md
@@ -21,6 +21,7 @@
         - diff
         - drop_nulls
         - ewm_mean
+        - exp
         - fill_null
         - filter
         - gather_every
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index 3a49856e..abe3f3e4 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -26,6 +26,7 @@
         - drop_nulls
         - dtype
         - ewm_mean
+        - exp
         - fill_null
         - filter
         - gather_every
diff --git a/docs/api-reference/typing.md b/docs/api-reference/typing.md
index b7637ee9..8c8c3740 100644
--- a/docs/api-reference/typing.md
+++ b/docs/api-reference/typing.md
@@ -17,6 +17,7 @@ Narwhals comes fully statically typed. In addition to `nw.DataFrame`, `nw.Expr`,
         - IntoFrameT
         - IntoSeries
         - IntoSeriesT
+        - IntoDType
         - SizeUnit
         - TimeUnit
         - AsofJoinStrategy
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 605949a5..af7993c5 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -199,4 +199,7 @@ class ArrowExpr(EagerExpr["ArrowDataFrame", ArrowSeries]):
     def log(self, base: float) -> Self:
         return self._reuse_series("log", base=base)
 
+    def exp(self) -> Self:
+        return self._reuse_series("exp")
+
     ewm_mean = not_implemented()
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index 42a01924..02d4c698 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -26,8 +26,7 @@ from narwhals._utils import Implementation
 if TYPE_CHECKING:
     from narwhals._arrow.typing import Incomplete
     from narwhals._utils import Version
-    from narwhals.dtypes import DType
-    from narwhals.typing import NonNestedLiteral
+    from narwhals.typing import IntoDType, NonNestedLiteral
 
 
 class ArrowNamespace(EagerNamespace[ArrowDataFrame, ArrowSeries, ArrowExpr, pa.Table]):
@@ -63,9 +62,7 @@ class ArrowNamespace(EagerNamespace[ArrowDataFrame, ArrowSeries, ArrowExpr, pa.T
             version=self._version,
         )
 
-    def lit(
-        self, value: NonNestedLiteral, dtype: DType | type[DType] | None
-    ) -> ArrowExpr:
+    def lit(self, value: NonNestedLiteral, dtype: IntoDType | None) -> ArrowExpr:
         def _lit_arrow_series(_: ArrowDataFrame) -> ArrowSeries:
             arrow_series = ArrowSeries.from_iterable(
                 data=[value], name="literal", context=self
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 073bc533..02596202 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -70,6 +70,7 @@ if TYPE_CHECKING:
         ClosedInterval,
         FillNullStrategy,
         Into1DArray,
+        IntoDType,
         NonNestedLiteral,
         NumericLiteral,
         PythonLiteral,
@@ -165,7 +166,7 @@ class ArrowSeries(EagerSeries["ChunkedArrayAny"]):
         *,
         context: _FullContext,
         name: str = "",
-        dtype: DType | type[DType] | None = None,
+        dtype: IntoDType | None = None,
     ) -> Self:
         version = context._version
         dtype_pa = narwhals_to_native_dtype(dtype, version) if dtype else None
@@ -549,7 +550,7 @@ class ArrowSeries(EagerSeries["ChunkedArrayAny"]):
     def is_nan(self) -> Self:
         return self._with_native(pc.is_nan(self.native), preserve_broadcast=True)
 
-    def cast(self, dtype: DType | type[DType]) -> Self:
+    def cast(self, dtype: IntoDType) -> Self:
         data_type = narwhals_to_native_dtype(dtype, self._version)
         return self._with_native(pc.cast(self.native, data_type), preserve_broadcast=True)
 
@@ -762,7 +763,7 @@ class ArrowSeries(EagerSeries["ChunkedArrayAny"]):
         old: Sequence[Any] | Mapping[Any, Any],
         new: Sequence[Any],
         *,
-        return_dtype: DType | type[DType] | None,
+        return_dtype: IntoDType | None,
     ) -> Self:
         # https://stackoverflow.com/a/79111029/4451315
         idxs = pc.index_in(self.native, pa.array(old))
@@ -1156,6 +1157,9 @@ class ArrowSeries(EagerSeries["ChunkedArrayAny"]):
     def log(self, base: float) -> Self:
         return self._with_native(pc.logb(self.native, lit(base)))
 
+    def exp(self) -> Self:
+        return self._with_native(pc.exp(self.native))
+
     @property
     def dt(self) -> ArrowSeriesDateTimeNamespace:
         return ArrowSeriesDateTimeNamespace(self)
diff --git a/narwhals/_arrow/utils.py b/narwhals/_arrow/utils.py
index 531eb265..d100448a 100644
--- a/narwhals/_arrow/utils.py
+++ b/narwhals/_arrow/utils.py
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
     from narwhals._duration import IntervalUnit
     from narwhals._utils import Version
     from narwhals.dtypes import DType
-    from narwhals.typing import PythonLiteral
+    from narwhals.typing import IntoDType, PythonLiteral
 
     # NOTE: stubs don't allow for `ChunkedArray[StructArray]`
     # Intended to represent the `.chunks` property storing `list[pa.StructArray]`
@@ -165,7 +165,7 @@ def native_to_narwhals_dtype(dtype: pa.DataType, version: Version) -> DType:  #
     return dtypes.Unknown()  # pragma: no cover
 
 
-def narwhals_to_native_dtype(dtype: DType | type[DType], version: Version) -> pa.DataType:  # noqa: C901, PLR0912
+def narwhals_to_native_dtype(dtype: IntoDType, version: Version) -> pa.DataType:  # noqa: C901, PLR0912
     dtypes = version.dtypes
     if isinstance_or_issubclass(dtype, dtypes.Decimal):
         msg = "Casting to Decimal is not supported yet."
diff --git a/narwhals/_compliant/expr.py b/narwhals/_compliant/expr.py
index 9490bc8b..965469ee 100644
--- a/narwhals/_compliant/expr.py
+++ b/narwhals/_compliant/expr.py
@@ -38,7 +38,6 @@ from narwhals._compliant.typing import (
 from narwhals._typing_compat import Protocol38, deprecated
 from narwhals._utils import _StoresCompliant, not_implemented
 from narwhals.dependencies import get_numpy, is_numpy_array
-from narwhals.dtypes import DType
 
 if TYPE_CHECKING:
     from typing import Mapping
@@ -56,9 +55,9 @@ if TYPE_CHECKING:
     )
     from narwhals._expression_parsing import ExprKind, ExprMetadata
     from narwhals._utils import Implementation, Version, _FullContext
-    from narwhals.dtypes import DType
     from narwhals.typing import (
         FillNullStrategy,
+        IntoDType,
         NonNestedLiteral,
         NumericLiteral,
         RankMethod,
@@ -117,7 +116,7 @@ class CompliantExpr(Protocol38[CompliantFrameT, CompliantSeriesOrNativeExprT_co]
     def all(self) -> Self: ...
     def any(self) -> Self: ...
     def alias(self, name: str) -> Self: ...
-    def cast(self, dtype: DType | type[DType]) -> Self: ...
+    def cast(self, dtype: IntoDType) -> Self: ...
     def count(self) -> Self: ...
     def min(self) -> Self: ...
     def max(self) -> Self: ...
@@ -140,6 +139,7 @@ class CompliantExpr(Protocol38[CompliantFrameT, CompliantSeriesOrNativeExprT_co]
         limit: int | None,
     ) -> Self: ...
     def diff(self) -> Self: ...
+    def exp(self) -> Self: ...
     def unique(self) -> Self: ...
     def len(self) -> Self: ...
     def log(self, base: float) -> Self: ...
@@ -166,7 +166,7 @@ class CompliantExpr(Protocol38[CompliantFrameT, CompliantSeriesOrNativeExprT_co]
         old: Sequence[Any] | Mapping[Any, Any],
         new: Sequence[Any],
         *,
-        return_dtype: DType | type[DType] | None,
+        return_dtype: IntoDType | None,
     ) -> Self: ...
     def over(self, partition_by: Sequence[str], order_by: Sequence[str]) -> Self: ...
     def sample(
@@ -183,7 +183,7 @@ class CompliantExpr(Protocol38[CompliantFrameT, CompliantSeriesOrNativeExprT_co]
     def map_batches(
         self,
         function: Callable[[CompliantSeries[Any]], CompliantExpr[Any, Any]],
-        return_dtype: DType | type[DType] | None,
+        return_dtype: IntoDType | None,
     ) -> Self: ...
 
     def clip(
@@ -514,7 +514,7 @@ class EagerExpr(
             scalar_kwargs=self._scalar_kwargs,
         )
 
-    def cast(self, dtype: DType | type[DType]) -> Self:
+    def cast(self, dtype: IntoDType) -> Self:
         return self._reuse_series("cast", dtype=dtype)
 
     def __eq__(self, other: Self | Any) -> Self:  # type: ignore[override]
@@ -677,7 +677,7 @@ class EagerExpr(
         old: Sequence[Any] | Mapping[Any, Any],
         new: Sequence[Any],
         *,
-        return_dtype: DType | type[DType] | None,
+        return_dtype: IntoDType | None,
     ) -> Self:
         return self._reuse_series(
             "replace_strict", old=old, new=new, return_dtype=return_dtype
@@ -804,7 +804,7 @@ class EagerExpr(
         )
 
     def map_batches(
-        self, function: Callable[[Any], Any], return_dtype: DType | type[DType] | None
+        self, function: Callable[[Any], Any], return_dtype: IntoDType | None
     ) -> Self:
         def func(df: EagerDataFrameT) -> Sequence[EagerSeriesT]:
             input_series_list = self(df)
diff --git a/narwhals/_compliant/namespace.py b/narwhals/_compliant/namespace.py
index 73712323..e73ccc23 100644
--- a/narwhals/_compliant/namespace.py
+++ b/narwhals/_compliant/namespace.py
@@ -39,7 +39,13 @@ if TYPE_CHECKING:
     from narwhals._utils import Implementation, Version
     from narwhals.dtypes import DType
     from narwhals.schema import Schema
-    from narwhals.typing import ConcatMethod, Into1DArray, NonNestedLiteral, _2DArray
+    from narwhals.typing import (
+        ConcatMethod,
+        Into1DArray,
+        IntoDType,
+        NonNestedLiteral,
+        _2DArray,
+    )
 
     Incomplete: TypeAlias = Any
 
@@ -68,9 +74,7 @@ class CompliantNamespace(Protocol[CompliantFrameT, CompliantExprT]):
         return self._expr.from_column_indices(*column_indices, context=self)
 
     def len(self) -> CompliantExprT: ...
-    def lit(
-        self, value: NonNestedLiteral, dtype: DType | type[DType] | None
-    ) -> CompliantExprT: ...
+    def lit(self, value: NonNestedLiteral, dtype: IntoDType | None) -> CompliantExprT: ...
     def all_horizontal(self, *exprs: CompliantExprT) -> CompliantExprT: ...
     def any_horizontal(self, *exprs: CompliantExprT) -> CompliantExprT: ...
     def sum_horizontal(self, *exprs: CompliantExprT) -> CompliantExprT: ...
diff --git a/narwhals/_compliant/series.py b/narwhals/_compliant/series.py
index fb0cb98c..706fd2b3 100644
--- a/narwhals/_compliant/series.py
+++ b/narwhals/_compliant/series.py
@@ -51,6 +51,7 @@ if TYPE_CHECKING:
         ClosedInterval,
         FillNullStrategy,
         Into1DArray,
+        IntoDType,
         MultiIndexSelector,
         NonNestedLiteral,
         NumericLiteral,
@@ -109,7 +110,7 @@ class CompliantSeries(
         *,
         context: _FullContext,
         name: str = "",
-        dtype: DType | type[DType] | None = None,
+        dtype: IntoDType | None = None,
     ) -> Self: ...
     def to_narwhals(self) -> Series[NativeSeriesT]:
         return self._version.series(self, level="full")
@@ -148,7 +149,7 @@ class CompliantSeries(
     def arg_max(self) -> int: ...
     def arg_min(self) -> int: ...
     def arg_true(self) -> Self: ...
-    def cast(self, dtype: DType | type[DType]) -> Self: ...
+    def cast(self, dtype: IntoDType) -> Self: ...
     def clip(
         self,
         lower_bound: Self | NumericLiteral | TemporalLiteral | None,
@@ -173,6 +174,7 @@ class CompliantSeries(
         min_samples: int,
         ignore_nulls: bool,
     ) -> Self: ...
+    def exp(self) -> Self: ...
     def fill_null(
         self,
         value: Self | NonNestedLiteral,
@@ -220,7 +222,7 @@ class CompliantSeries(
         old: Sequence[Any] | Mapping[Any, Any],
         new: Sequence[Any],
         *,
-        return_dtype: DType | type[DType] | None,
+        return_dtype: IntoDType | None,
     ) -> Self: ...
     def rolling_mean(
         self, window_size: int, *, min_samples: int, center: bool
diff --git a/narwhals/_compliant/when_then.py b/narwhals/_compliant/when_then.py
index a2a2b4fe..1de91f93 100644
--- a/narwhals/_compliant/when_then.py
+++ b/narwhals/_compliant/when_then.py
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
     from typing_extensions import Self, TypeAlias
 
     from narwhals._compliant.typing import EvalSeries, ScalarKwargs
+    from narwhals._compliant.window import WindowInputs
     from narwhals._utils import Implementation, Version, _FullContext
     from narwhals.typing import NonNestedLiteral
 
@@ -50,6 +51,9 @@ class CompliantWhen(Protocol38[FrameT, SeriesT, ExprT]):
     @property
     def _then(self) -> type[CompliantThen[FrameT, SeriesT, ExprT]]: ...
     def __call__(self, compliant_frame: FrameT, /) -> Sequence[SeriesT]: ...
+    def _window_function(
+        self, compliant_frame: FrameT, window_inputs: WindowInputs[Any]
+    ) -> Sequence[SeriesT]: ...
 
     def then(
         self, value: IntoExpr[SeriesT, ExprT], /
@@ -124,9 +128,7 @@ class LazyThen(
         obj = cls.__new__(cls)
         obj._call = when
 
-        # This may require more complicated logic if we want to push down the
-        # `over`: https://github.com/narwhals-dev/narwhals/issues/2652.
-        obj._window_function = None
+        obj._window_function = when._window_function
 
         obj._when_value = when
         obj._depth = 0
@@ -154,11 +156,13 @@ class EagerWhen(
         is_expr = self._condition._is_expr
         when: EagerSeriesT = self._condition(df)[0]
         then: EagerSeriesT
+
         if is_expr(self._then_value):
             then = self._then_value(df)[0]
         else:
             then = when.alias("literal")._from_scalar(self._then_value)
             then._broadcast = True
+
         if is_expr(self._otherwise_value):
             otherwise = self._otherwise_value(df)[0]
         elif self._otherwise_value is not None:
@@ -175,7 +179,6 @@ class LazyWhen(
 ):
     when: Callable[..., NativeExprT]
     lit: Callable[..., NativeExprT]
-    _window_function: WindowFunction[CompliantLazyFrameT, NativeExprT] | None
 
     def __call__(self, df: CompliantLazyFrameT) -> Sequence[NativeExprT]:
         is_expr = self._condition._is_expr
@@ -197,13 +200,33 @@ class LazyWhen(
         obj = cls.__new__(cls)
         obj._condition = condition
 
-        # This may require more complicated logic if we want to push down the
-        # `over`: https://github.com/narwhals-dev/narwhals/issues/2652.
-        obj._window_function = None
-
         obj._then_value = None
         obj._otherwise_value = None
         obj._implementation = context._implementation
         obj._backend_version = context._backend_version
         obj._version = context._version
         return obj
+
+    def _window_function(
+        self, df: CompliantLazyFrameT, window_inputs: WindowInputs[NativeExprT]
+    ) -> Sequence[NativeExprT]:
+        is_expr = self._condition._is_expr
+        condition = self._condition.window_function(df, window_inputs)[0]
+        then_ = self._then_value
+        then = (
+            then_.window_function(df, window_inputs)[0]
+            if is_expr(then_)
+            else self.lit(then_)
+        )
+
+        other_ = self._otherwise_value
+        if other_ is None:
+            result = self.when(condition, then)
+        else:
+            other = (
+                other_.window_function(df, window_inputs)[0]
+                if is_expr(other_)
+                else self.lit(other_)
+            )
+            result = self.when(condition, then).otherwise(other)  # type: ignore  # noqa: PGH003
+        return [result]
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 193c0db9..aa51997b 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -30,9 +30,9 @@ if TYPE_CHECKING:
     from narwhals._dask.namespace import DaskNamespace
     from narwhals._expression_parsing import ExprKind, ExprMetadata
     from narwhals._utils import Version, _FullContext
-    from narwhals.dtypes import DType
     from narwhals.typing import (
         FillNullStrategy,
+        IntoDType,
         NonNestedLiteral,
         NumericLiteral,
         RollingInterpolationMethod,
@@ -81,7 +81,9 @@ class DaskExpr(
 
     def broadcast(self, kind: Literal[ExprKind.AGGREGATION, ExprKind.LITERAL]) -> Self:
         def func(df: DaskLazyFrame) -> list[dx.Series]:
-            return [result[0] for result in self(df)]
+            # result.loc[0][0] is a workaround for dask~<=2024.10.0/dask_expr~<=1.1.16
+            #   that raised a KeyErrror for result[0] during collection.
+            return [result.loc[0][0] for result in self(df)]
 
         return self.__class__(
             func,
@@ -633,7 +635,7 @@ class DaskExpr(
             version=self._version,
         )
 
-    def cast(self, dtype: DType | type[DType]) -> Self:
+    def cast(self, dtype: IntoDType) -> Self:
         def func(expr: dx.Series) -> dx.Series:
             native_dtype = narwhals_to_native_dtype(dtype, self._version)
             return expr.astype(native_dtype)
@@ -653,6 +655,11 @@ class DaskExpr(
 
         return self._with_callable(_log, "log")
 
+    def exp(self) -> Self:
+        import dask.array as da
+
+        return self._with_callable(da.exp, "exp")
+
     @property
     def str(self) -> DaskExprStringNamespace:
         return DaskExprStringNamespace(self)
diff --git a/narwhals/_dask/namespace.py b/narwhals/_dask/namespace.py
index 5e9150f2..3e0506de 100644
--- a/narwhals/_dask/namespace.py
+++ b/narwhals/_dask/namespace.py
@@ -18,6 +18,7 @@ from narwhals._dask.utils import (
     validate_comparand,
 )
 from narwhals._expression_parsing import (
+    ExprKind,
     combine_alias_output_names,
     combine_evaluate_output_names,
 )
@@ -27,8 +28,7 @@ if TYPE_CHECKING:
     import dask.dataframe.dask_expr as dx
 
     from narwhals._utils import Version
-    from narwhals.dtypes import DType
-    from narwhals.typing import ConcatMethod, NonNestedLiteral
+    from narwhals.typing import ConcatMethod, IntoDType, NonNestedLiteral
 
 
 class DaskNamespace(
@@ -53,7 +53,7 @@ class DaskNamespace(
         self._backend_version = backend_version
         self._version = version
 
-    def lit(self, value: NonNestedLiteral, dtype: DType | type[DType] | None) -> DaskExpr:
+    def lit(self, value: NonNestedLiteral, dtype: IntoDType | None) -> DaskExpr:
         def func(df: DaskLazyFrame) -> list[dx.Series]:
             if dtype is not None:
                 native_dtype = narwhals_to_native_dtype(dtype, self._version)
@@ -283,23 +283,36 @@ class DaskWhen(CompliantWhen[DaskLazyFrame, "dx.Series", DaskExpr]):
         return DaskThen
 
     def __call__(self, df: DaskLazyFrame) -> Sequence[dx.Series]:
-        condition = self._condition(df)[0]
+        then_value = (
+            self._then_value(df)[0]
+            if isinstance(self._then_value, DaskExpr)
+            else self._then_value
+        )
+        otherwise_value = (
+            self._otherwise_value(df)[0]
+            if isinstance(self._otherwise_value, DaskExpr)
+            else self._otherwise_value
+        )
 
-        if isinstance(self._then_value, DaskExpr):
-            then_value = self._then_value(df)[0]
-        else:
-            then_value = self._then_value
-        (then_series,) = align_series_full_broadcast(df, then_value)
-        validate_comparand(condition, then_series)
+        condition = self._condition(df)[0]
+        # re-evaluate DataFrame if the condition aggregates to force
+        #   then/otherwise to be evaluated against the aggregated frame
+        assert self._condition._metadata is not None  # noqa: S101
+        if self._condition._metadata.is_scalar_like:
+            new_df = df._with_native(condition.to_frame())
+            condition = self._condition.broadcast(ExprKind.AGGREGATION)(df)[0]
+            df = new_df
 
         if self._otherwise_value is None:
-            return [then_series.where(condition)]
-
-        if isinstance(self._otherwise_value, DaskExpr):
-            otherwise_value = self._otherwise_value(df)[0]
-        else:
-            return [then_series.where(condition, self._otherwise_value)]  # pyright: ignore[reportArgumentType]
-        (otherwise_series,) = align_series_full_broadcast(df, otherwise_value)
+            (condition, then_series) = align_series_full_broadcast(
+                df, condition, then_value
+            )
+            validate_comparand(condition, then_series)
+            return [then_series.where(condition)]  # pyright: ignore[reportArgumentType]
+        (condition, then_series, otherwise_series) = align_series_full_broadcast(
+            df, condition, then_value, otherwise_value
+        )
+        validate_comparand(condition, then_series)
         validate_comparand(condition, otherwise_series)
         return [then_series.where(condition, otherwise_series)]  # pyright: ignore[reportArgumentType]
 
diff --git a/narwhals/_dask/utils.py b/narwhals/_dask/utils.py
index 2e370912..fa2a2b0c 100644
--- a/narwhals/_dask/utils.py
+++ b/narwhals/_dask/utils.py
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
 
     from narwhals._dask.dataframe import DaskLazyFrame
     from narwhals._dask.expr import DaskExpr
-    from narwhals.dtypes import DType
+    from narwhals.typing import IntoDType
 else:
     try:
         import dask.dataframe.dask_expr as dx
@@ -88,7 +88,7 @@ def validate_comparand(lhs: dx.Series, rhs: dx.Series) -> None:
         raise RuntimeError(msg)
 
 
-def narwhals_to_native_dtype(dtype: DType | type[DType], version: Version) -> Any:  # noqa: C901, PLR0912
+def narwhals_to_native_dtype(dtype: IntoDType, version: Version) -> Any:  # noqa: C901, PLR0912
     dtypes = version.dtypes
     if isinstance_or_issubclass(dtype, dtypes.Float64):
         return "float64"
diff --git a/narwhals/_duckdb/expr.py b/narwhals/_duckdb/expr.py
index f2b37742..b3d55f31 100644
--- a/narwhals/_duckdb/expr.py
+++ b/narwhals/_duckdb/expr.py
@@ -38,9 +38,9 @@ if TYPE_CHECKING:
     from narwhals._duckdb.namespace import DuckDBNamespace
     from narwhals._expression_parsing import ExprMetadata
     from narwhals._utils import Version, _FullContext
-    from narwhals.dtypes import DType
     from narwhals.typing import (
         FillNullStrategy,
+        IntoDType,
         NonNestedLiteral,
         NumericLiteral,
         RankMethod,
@@ -777,7 +777,7 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "Expression"]):
 
         return self._with_elementwise(_fill_constant, value=value)
 
-    def cast(self, dtype: DType | type[DType]) -> Self:
+    def cast(self, dtype: IntoDType) -> Self:
         def func(expr: Expression) -> Expression:
             native_dtype = narwhals_to_native_dtype(dtype, self._version)
             return expr.cast(DuckDBPyType(native_dtype))
@@ -872,6 +872,12 @@ class DuckDBExpr(LazyExpr["DuckDBLazyFrame", "Expression"]):
 
         return self._with_elementwise(_log)
 
+    def exp(self) -> Self:
+        def _exp(expr: Expression) -> Expression:
+            return FunctionExpression("exp", expr)
+
+        return self._with_elementwise(_exp)
+
     @property
     def str(self) -> DuckDBExprStringNamespace:
         return DuckDBExprStringNamespace(self)
diff --git a/narwhals/_duckdb/namespace.py b/narwhals/_duckdb/namespace.py
index f6b697c3..3616f2e1 100644
--- a/narwhals/_duckdb/namespace.py
+++ b/narwhals/_duckdb/namespace.py
@@ -23,8 +23,7 @@ from narwhals._utils import Implementation
 if TYPE_CHECKING:
     from narwhals._duckdb.expr import DuckDBWindowInputs
     from narwhals._utils import Version
-    from narwhals.dtypes import DType
-    from narwhals.typing import ConcatMethod, NonNestedLiteral
+    from narwhals.typing import ConcatMethod, IntoDType, NonNestedLiteral
 
 
 class DuckDBNamespace(
@@ -156,9 +155,7 @@ class DuckDBNamespace(
     def when(self, predicate: DuckDBExpr) -> DuckDBWhen:
         return DuckDBWhen.from_expr(predicate, context=self)
 
-    def lit(
-        self, value: NonNestedLiteral, dtype: DType | type[DType] | None
-    ) -> DuckDBExpr:
+    def lit(self, value: NonNestedLiteral, dtype: IntoDType | None) -> DuckDBExpr:
         def func(_df: DuckDBLazyFrame) -> list[Expression]:
             if dtype is not None:
                 return [
@@ -199,5 +196,12 @@ class DuckDBWhen(LazyWhen["DuckDBLazyFrame", Expression, DuckDBExpr]):
         self.lit = lit
         return super().__call__(df)
 
+    def _window_function(
+        self, df: DuckDBLazyFrame, window_inputs: DuckDBWindowInputs
+    ) -> Sequence[Expression]:
+        self.when = when
+        self.lit = lit
+        return super()._window_function(df, window_inputs)
+
 
 class DuckDBThen(LazyThen["DuckDBLazyFrame", Expression, DuckDBExpr], DuckDBExpr): ...
diff --git a/narwhals/_duckdb/utils.py b/narwhals/_duckdb/utils.py
index c57a1fc0..c5d4872a 100644
--- a/narwhals/_duckdb/utils.py
+++ b/narwhals/_duckdb/utils.py
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
     from narwhals._duckdb.dataframe import DuckDBLazyFrame
     from narwhals._duckdb.expr import DuckDBExpr
     from narwhals.dtypes import DType
+    from narwhals.typing import IntoDType
 
 UNITS_DICT = {
     "y": "year",
@@ -189,7 +190,7 @@ def _non_nested_native_to_narwhals_dtype(duckdb_dtype_id: str, version: Version)
     }.get(duckdb_dtype_id, dtypes.Unknown())
 
 
-def narwhals_to_native_dtype(dtype: DType | type[DType], version: Version) -> str:  # noqa: C901, PLR0912, PLR0915
+def narwhals_to_native_dtype(dtype: IntoDType, version: Version) -> str:  # noqa: C901, PLR0912, PLR0915
     dtypes = version.dtypes
     if isinstance_or_issubclass(dtype, dtypes.Decimal):
         msg = "Casting to Decimal is not supported yet."
diff --git a/narwhals/_ibis/expr.py b/narwhals/_ibis/expr.py
index 5089b002..f74e9624 100644
--- a/narwhals/_ibis/expr.py
+++ b/narwhals/_ibis/expr.py
@@ -29,8 +29,7 @@ if TYPE_CHECKING:
     from narwhals._ibis.dataframe import IbisLazyFrame
     from narwhals._ibis.namespace import IbisNamespace
     from narwhals._utils import Version, _FullContext
-    from narwhals.dtypes import DType
-    from narwhals.typing import RankMethod, RollingInterpolationMethod
+    from narwhals.typing import IntoDType, RankMethod, RollingInterpolationMethod
 
     ExprT = TypeVar("ExprT", bound=ir.Value)
     IbisWindowFunction = WindowFunction[IbisLazyFrame, ir.Value]
@@ -599,7 +598,7 @@ class IbisExpr(LazyExpr["IbisLazyFrame", "ir.Column"]):
 
         return self._with_callable(_fill_null, value=value)
 
-    def cast(self, dtype: DType | type[DType]) -> Self:
+    def cast(self, dtype: IntoDType) -> Self:
         def _func(expr: ir.Column) -> ir.Value:
             native_dtype = narwhals_to_native_dtype(dtype, self._version)
             # ibis `cast` overloads do not include DataType, only literals
@@ -660,6 +659,12 @@ class IbisExpr(LazyExpr["IbisLazyFrame", "ir.Column"]):
 
         return self._with_callable(_log)
 
+    def exp(self) -> Self:
+        def _exp(expr: ir.NumericColumn) -> ir.Value:
+            return expr.exp()
+
+        return self._with_callable(_exp)
+
     @property
     def str(self) -> IbisExprStringNamespace:
         return IbisExprStringNamespace(self)
diff --git a/narwhals/_ibis/namespace.py b/narwhals/_ibis/namespace.py
index 622828a9..25393cf4 100644
--- a/narwhals/_ibis/namespace.py
+++ b/narwhals/_ibis/namespace.py
@@ -21,8 +21,7 @@ from narwhals._utils import Implementation, requires
 
 if TYPE_CHECKING:
     from narwhals._utils import Version
-    from narwhals.dtypes import DType
-    from narwhals.typing import ConcatMethod
+    from narwhals.typing import ConcatMethod, IntoDType
 
 
 class IbisNamespace(LazyNamespace[IbisLazyFrame, IbisExpr, "ir.Table"]):
@@ -178,7 +177,7 @@ class IbisNamespace(LazyNamespace[IbisLazyFrame, IbisExpr, "ir.Table"]):
     def when(self, predicate: IbisExpr) -> IbisWhen:
         return IbisWhen.from_expr(predicate, context=self)
 
-    def lit(self, value: Any, dtype: DType | type[DType] | None) -> IbisExpr:
+    def lit(self, value: Any, dtype: IntoDType | None) -> IbisExpr:
         def func(_df: IbisLazyFrame) -> list[ir.Value]:
             ibis_dtype = narwhals_to_native_dtype(dtype, self._version) if dtype else None
             return [lit(value, ibis_dtype)]
diff --git a/narwhals/_ibis/utils.py b/narwhals/_ibis/utils.py
index a24d067c..477781fc 100644
--- a/narwhals/_ibis/utils.py
+++ b/narwhals/_ibis/utils.py
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
     from narwhals._ibis.expr import IbisExpr
     from narwhals._utils import Version
     from narwhals.dtypes import DType
+    from narwhals.typing import IntoDType
 
 lit = ibis.literal
 """Alias for `ibis.literal`."""
@@ -157,7 +158,7 @@ def is_floating(obj: IbisDataType) -> TypeIs[ibis_dtypes.Floating]:
 
 
 def narwhals_to_native_dtype(  # noqa: C901, PLR0912
-    dtype: DType | type[DType], version: Version
+    dtype: IntoDType, version: Version
 ) -> IbisDataType:
     dtypes = version.dtypes
 
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index eb897054..0cd9958d 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -397,3 +397,6 @@ class PandasLikeExpr(EagerExpr["PandasLikeDataFrame", PandasLikeSeries]):
 
     def log(self, base: float) -> Self:
         return self._reuse_series("log", base=base)
+
+    def exp(self) -> Self:
+        return self._reuse_series("exp")
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 7d149e4c..5612c856 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -21,8 +21,7 @@ from narwhals._pandas_like.utils import align_series_full_broadcast
 if TYPE_CHECKING:
     from narwhals._pandas_like.typing import NDFrameT
     from narwhals._utils import Implementation, Version
-    from narwhals.dtypes import DType
-    from narwhals.typing import NonNestedLiteral
+    from narwhals.typing import IntoDType, NonNestedLiteral
 
 VERTICAL: Literal[0] = 0
 HORIZONTAL: Literal[1] = 1
@@ -58,9 +57,7 @@ class PandasLikeNamespace(
         self._backend_version = backend_version
         self._version = version
 
-    def lit(
-        self, value: NonNestedLiteral, dtype: DType | type[DType] | None
-    ) -> PandasLikeExpr:
+    def lit(self, value: NonNestedLiteral, dtype: IntoDType | None) -> PandasLikeExpr:
         def _lit_pandas_series(df: PandasLikeDataFrame) -> PandasLikeSeries:
             pandas_series = self._series.from_iterable(
                 data=[value],
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index ee9af2dd..0ea4e833 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -47,6 +47,7 @@ if TYPE_CHECKING:
         ClosedInterval,
         FillNullStrategy,
         Into1DArray,
+        IntoDType,
         NonNestedLiteral,
         NumericLiteral,
         RankMethod,
@@ -179,7 +180,7 @@ class PandasLikeSeries(EagerSeries[Any]):
         *,
         context: _FullContext,
         name: str = "",
-        dtype: DType | type[DType] | None = None,
+        dtype: IntoDType | None = None,
         index: Any = None,
     ) -> Self:
         implementation = context._implementation
@@ -296,7 +297,7 @@ class PandasLikeSeries(EagerSeries[Any]):
         else:
             self.native.iloc[indices.native] = values_native
 
-    def cast(self, dtype: DType | type[DType]) -> Self:
+    def cast(self, dtype: IntoDType) -> Self:
         pd_dtype = narwhals_to_native_dtype(
             dtype,
             dtype_backend=get_dtype_backend(self.native.dtype, self._implementation),
@@ -619,7 +620,7 @@ class PandasLikeSeries(EagerSeries[Any]):
         old: Sequence[Any] | Mapping[Any, Any],
         new: Sequence[Any],
         *,
-        return_dtype: DType | type[DType] | None,
+        return_dtype: IntoDType | None,
     ) -> PandasLikeSeries:
         tmp_name = f"{self.name}_tmp"
         dtype_backend = get_dtype_backend(self.native.dtype, self._implementation)
@@ -1045,6 +1046,42 @@ class PandasLikeSeries(EagerSeries[Any]):
             result_native = np.log(native) / np.log(base)
         return self._with_native(result_native)
 
+    def exp(self) -> Self:
+        native = self.native
+        implementation = self._implementation
+
+        dtype_backend = get_dtype_backend(native.dtype, implementation=implementation)
+
+        if implementation.is_cudf():
+            import cupy as cp  # ignore-banned-import  # cuDF dependency.
+
+            native = self.native
+            exp_arr = cp.exp(native)
+            result_native = type(native)(exp_arr, index=native.index, name=native.name)
+            return self._with_native(result_native)
+
+        if dtype_backend == "pyarrow":
+            import pyarrow.compute as pc
+
+            from narwhals._arrow.utils import native_to_narwhals_dtype
+
+            ca = native.array._pa_array
+            result_arr = cast("ChunkedArrayAny", pc.exp(ca))
+            nw_dtype = native_to_narwhals_dtype(result_arr.type, self._version)
+            out_dtype = narwhals_to_native_dtype(
+                nw_dtype,
+                "pyarrow",
+                self._implementation,
+                self._backend_version,
+                self._version,
+            )
+            result_native = native.__class__(
+                result_arr, dtype=out_dtype, index=native.index, name=native.name
+            )
+        else:
+            result_native = np.exp(native)
+        return self._with_native(result_native)
+
     @property
     def str(self) -> PandasLikeSeriesStringNamespace:
         return PandasLikeSeriesStringNamespace(self)
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 7371b78e..bc75c14a 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
     from narwhals._pandas_like.expr import PandasLikeExpr
     from narwhals._pandas_like.series import PandasLikeSeries
     from narwhals.dtypes import DType
-    from narwhals.typing import DTypeBackend, TimeUnit, _1DArray
+    from narwhals.typing import DTypeBackend, IntoDType, TimeUnit, _1DArray
 
     ExprT = TypeVar("ExprT", bound=PandasLikeExpr)
 
@@ -349,7 +349,7 @@ def is_pyarrow_dtype_backend(dtype: Any, implementation: Implementation) -> bool
 
 
 def narwhals_to_native_dtype(  # noqa: C901, PLR0912, PLR0915
-    dtype: DType | type[DType],
+    dtype: IntoDType,
     dtype_backend: DTypeBackend,
     implementation: Implementation,
     backend_version: tuple[int, ...],
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
index f708df5a..eb5b5f26 100644
--- a/narwhals/_polars/expr.py
+++ b/narwhals/_polars/expr.py
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
     from narwhals._polars.dataframe import Method
     from narwhals._polars.namespace import PolarsNamespace
     from narwhals._utils import Version
-    from narwhals.dtypes import DType
+    from narwhals.typing import IntoDType
 
 
 class PolarsExpr:
@@ -61,7 +61,7 @@ class PolarsExpr:
         name = "min_periods" if self._backend_version < (1, 21, 0) else "min_samples"
         return {name: min_samples}
 
-    def cast(self, dtype: DType | type[DType]) -> Self:
+    def cast(self, dtype: IntoDType) -> Self:
         dtype_pl = narwhals_to_native_dtype(dtype, self._version, self._backend_version)
         return self._with_native(self.native.cast(dtype_pl))
 
@@ -139,7 +139,7 @@ class PolarsExpr:
         return self._with_native(native)
 
     def map_batches(
-        self, function: Callable[[Any], Any], return_dtype: DType | type[DType] | None
+        self, function: Callable[[Any], Any], return_dtype: IntoDType | None
     ) -> Self:
         return_dtype_pl = (
             narwhals_to_native_dtype(return_dtype, self._version, self._backend_version)
@@ -155,7 +155,7 @@ class PolarsExpr:
         old: Sequence[Any] | Mapping[Any, Any],
         new: Sequence[Any],
         *,
-        return_dtype: DType | type[DType] | None,
+        return_dtype: IntoDType | None,
     ) -> Self:
         return_dtype_pl = (
             narwhals_to_native_dtype(return_dtype, self._version, self._backend_version)
@@ -278,6 +278,7 @@ class PolarsExpr:
     cum_sum: Method[Self]
     diff: Method[Self]
     drop_nulls: Method[Self]
+    exp: Method[Self]
     fill_null: Method[Self]
     gather_every: Method[Self]
     head: Method[Self]
diff --git a/narwhals/_polars/namespace.py b/narwhals/_polars/namespace.py
index 4771cf23..4dec34c3 100644
--- a/narwhals/_polars/namespace.py
+++ b/narwhals/_polars/namespace.py
@@ -29,7 +29,7 @@ if TYPE_CHECKING:
     from narwhals._polars.typing import FrameT
     from narwhals._utils import Version, _FullContext
     from narwhals.schema import Schema
-    from narwhals.typing import Into1DArray, TimeUnit, _2DArray
+    from narwhals.typing import Into1DArray, IntoDType, TimeUnit, _2DArray
 
 
 class PolarsNamespace:
@@ -154,7 +154,7 @@ class PolarsNamespace:
             )
         return self._lazyframe.from_native(result, context=self)
 
-    def lit(self, value: Any, dtype: DType | type[DType] | None) -> PolarsExpr:
+    def lit(self, value: Any, dtype: IntoDType | None) -> PolarsExpr:
         if dtype is not None:
             return self._expr(
                 pl.lit(
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index 00664a09..2a4325ef 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -37,7 +37,7 @@ if TYPE_CHECKING:
     from narwhals._utils import Version, _FullContext
     from narwhals.dtypes import DType
     from narwhals.series import Series
-    from narwhals.typing import Into1DArray, MultiIndexSelector, _1DArray
+    from narwhals.typing import Into1DArray, IntoDType, MultiIndexSelector, _1DArray
 
     T = TypeVar("T")
 
@@ -78,6 +78,7 @@ INHERITED_METHODS = frozenset(
         "cum_sum",
         "diff",
         "drop_nulls",
+        "exp",
         "fill_null",
         "filter",
         "gather_every",
@@ -161,7 +162,7 @@ class PolarsSeries:
         *,
         context: _FullContext,
         name: str = "",
-        dtype: DType | type[DType] | None = None,
+        dtype: IntoDType | None = None,
     ) -> Self:
         version = context._version
         backend_version = context._backend_version
@@ -256,7 +257,7 @@ class PolarsSeries:
             return self._from_native_object(self.native.__getitem__(item.native))
         return self._from_native_object(self.native.__getitem__(item))
 
-    def cast(self, dtype: DType | type[DType]) -> Self:
+    def cast(self, dtype: IntoDType) -> Self:
         dtype_pl = narwhals_to_native_dtype(dtype, self._version, self._backend_version)
         return self._with_native(self.native.cast(dtype_pl))
 
@@ -266,7 +267,7 @@ class PolarsSeries:
         old: Sequence[Any] | Mapping[Any, Any],
         new: Sequence[Any],
         *,
-        return_dtype: DType | type[DType] | None,
+        return_dtype: IntoDType | None,
     ) -> Self:
         ser = self.native
         dtype = (
@@ -627,6 +628,7 @@ class PolarsSeries:
     cum_sum: Method[Self]
     diff: Method[Self]
     drop_nulls: Method[Self]
+    exp: Method[Self]
     fill_null: Method[Self]
     filter: Method[Self]
     gather_every: Method[Self]
diff --git a/narwhals/_polars/utils.py b/narwhals/_polars/utils.py
index 40277405..bb15dfb7 100644
--- a/narwhals/_polars/utils.py
+++ b/narwhals/_polars/utils.py
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
 
     from narwhals._utils import _StoresNative
     from narwhals.dtypes import DType
+    from narwhals.typing import IntoDType
 
     T = TypeVar("T")
     NativeT = TypeVar(
@@ -148,7 +149,7 @@ def native_to_narwhals_dtype(  # noqa: C901, PLR0912
 
 
 def narwhals_to_native_dtype(  # noqa: C901, PLR0912
-    dtype: DType | type[DType], version: Version, backend_version: tuple[int, ...]
+    dtype: IntoDType, version: Version, backend_version: tuple[int, ...]
 ) -> pl.DataType:
     dtypes = version.dtypes
     if dtype == dtypes.Float64:
diff --git a/narwhals/_spark_like/expr.py b/narwhals/_spark_like/expr.py
index 0247fa3b..8010ed8c 100644
--- a/narwhals/_spark_like/expr.py
+++ b/narwhals/_spark_like/expr.py
@@ -44,9 +44,9 @@ if TYPE_CHECKING:
     from narwhals._spark_like.dataframe import SparkLikeLazyFrame
     from narwhals._spark_like.namespace import SparkLikeNamespace
     from narwhals._utils import Version, _FullContext
-    from narwhals.dtypes import DType
     from narwhals.typing import (
         FillNullStrategy,
+        IntoDType,
         NonNestedLiteral,
         NumericLiteral,
         RankMethod,
@@ -514,7 +514,7 @@ class SparkLikeExpr(LazyExpr["SparkLikeLazyFrame", "Column"]):
 
         return self._with_callable(f)._with_window_function(window_f)
 
-    def cast(self, dtype: DType | type[DType]) -> Self:
+    def cast(self, dtype: IntoDType) -> Self:
         def _cast(expr: Column) -> Column:
             spark_dtype = narwhals_to_native_dtype(
                 dtype, self._version, self._native_dtypes
@@ -923,6 +923,12 @@ class SparkLikeExpr(LazyExpr["SparkLikeLazyFrame", "Column"]):
 
         return self._with_elementwise(_log)
 
+    def exp(self) -> Self:
+        def _exp(expr: Column) -> Column:
+            return self._F.exp(expr)
+
+        return self._with_elementwise(_exp)
+
     @property
     def str(self) -> SparkLikeExprStringNamespace:
         return SparkLikeExprStringNamespace(self)
diff --git a/narwhals/_spark_like/namespace.py b/narwhals/_spark_like/namespace.py
index 818c7ebb..7ad42ff4 100644
--- a/narwhals/_spark_like/namespace.py
+++ b/narwhals/_spark_like/namespace.py
@@ -24,8 +24,7 @@ if TYPE_CHECKING:
     from narwhals._spark_like.dataframe import SQLFrameDataFrame  # noqa: F401
     from narwhals._spark_like.expr import SparkWindowInputs
     from narwhals._utils import Implementation, Version
-    from narwhals.dtypes import DType
-    from narwhals.typing import ConcatMethod, NonNestedLiteral
+    from narwhals.typing import ConcatMethod, IntoDType, NonNestedLiteral
 
 
 class SparkLikeNamespace(
@@ -97,9 +96,7 @@ class SparkLikeNamespace(
             implementation=self._implementation,
         )
 
-    def lit(
-        self, value: NonNestedLiteral, dtype: DType | type[DType] | None
-    ) -> SparkLikeExpr:
+    def lit(self, value: NonNestedLiteral, dtype: IntoDType | None) -> SparkLikeExpr:
         def _lit(df: SparkLikeLazyFrame) -> list[Column]:
             column = df._F.lit(value)
             if dtype:
@@ -280,6 +277,13 @@ class SparkLikeWhen(LazyWhen[SparkLikeLazyFrame, "Column", SparkLikeExpr]):
         self.lit = df._F.lit
         return super().__call__(df)
 
+    def _window_function(
+        self, df: SparkLikeLazyFrame, window_inputs: SparkWindowInputs
+    ) -> Sequence[Column]:
+        self.when = df._F.when
+        self.lit = df._F.lit
+        return super()._window_function(df, window_inputs)
+
 
 class SparkLikeThen(
     LazyThen[SparkLikeLazyFrame, "Column", SparkLikeExpr], SparkLikeExpr
diff --git a/narwhals/_spark_like/utils.py b/narwhals/_spark_like/utils.py
index 2a8c6ca6..95fdc960 100644
--- a/narwhals/_spark_like/utils.py
+++ b/narwhals/_spark_like/utils.py
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
     from narwhals._spark_like.expr import SparkLikeExpr
     from narwhals._utils import Version
     from narwhals.dtypes import DType
+    from narwhals.typing import IntoDType
 
     _NativeDType: TypeAlias = sqlframe_types.DataType
     SparkSession = Session[Any, Any, Any, Any, Any, Any, Any]
@@ -127,7 +128,7 @@ def fetch_session_time_zone(session: SparkSession) -> str:
 
 
 def narwhals_to_native_dtype(  # noqa: C901, PLR0912
-    dtype: DType | type[DType], version: Version, spark_types: ModuleType
+    dtype: IntoDType, version: Version, spark_types: ModuleType
 ) -> _NativeDType:
     dtypes = version.dtypes
     if TYPE_CHECKING:
diff --git a/narwhals/dependencies.py b/narwhals/dependencies.py
index 9d4731ab..d775677d 100644
--- a/narwhals/dependencies.py
+++ b/narwhals/dependencies.py
@@ -100,6 +100,8 @@ def get_ibis() -> Any:
 
 def get_dask_expr() -> Any:  # pragma: no cover
     """Get dask_expr module (if already imported - else return None)."""
+    if (dd := get_dask_dataframe()) is not None and hasattr(dd, "dask_expr"):
+        return dd.dask_expr
     return sys.modules.get("dask_expr", None)
 
 
diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py
index f99c477c..3756e9a8 100644
--- a/narwhals/dtypes.py
+++ b/narwhals/dtypes.py
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
 
     from typing_extensions import Self
 
-    from narwhals.typing import TimeUnit
+    from narwhals.typing import IntoDType, TimeUnit
 
 
 def _validate_dtype(dtype: DType | type[DType]) -> None:
@@ -519,9 +519,9 @@ class Field:
     """
 
     name: str
-    dtype: type[DType] | DType
+    dtype: IntoDType
 
-    def __init__(self, name: str, dtype: type[DType] | DType) -> None:
+    def __init__(self, name: str, dtype: IntoDType) -> None:
         self.name = name
         self.dtype = dtype
 
@@ -555,9 +555,7 @@ class Struct(NestedType):
 
     fields: list[Field]
 
-    def __init__(
-        self, fields: Sequence[Field] | Mapping[str, DType | type[DType]]
-    ) -> None:
+    def __init__(self, fields: Sequence[Field] | Mapping[str, IntoDType]) -> None:
         if isinstance(fields, Mapping):
             self.fields = list(starmap(Field, fields.items()))
         else:
@@ -578,11 +576,11 @@ class Struct(NestedType):
     def __hash__(self) -> int:
         return hash((self.__class__, tuple(self.fields)))
 
-    def __iter__(self) -> Iterator[tuple[str, DType | type[DType]]]:  # pragma: no cover
+    def __iter__(self) -> Iterator[tuple[str, IntoDType]]:  # pragma: no cover
         for fld in self.fields:
             yield fld.name, fld.dtype
 
-    def __reversed__(self) -> Iterator[tuple[str, DType | type[DType]]]:
+    def __reversed__(self) -> Iterator[tuple[str, IntoDType]]:
         for fld in reversed(self.fields):
             yield fld.name, fld.dtype
 
@@ -590,7 +588,7 @@ class Struct(NestedType):
         class_name = self.__class__.__name__
         return f"{class_name}({dict(self)})"
 
-    def to_schema(self) -> OrderedDict[str, DType | type[DType]]:
+    def to_schema(self) -> OrderedDict[str, IntoDType]:
         """Return Struct dtype as a schema dict.
 
         Returns:
@@ -614,9 +612,9 @@ class List(NestedType):
        List(String)
     """
 
-    inner: DType | type[DType]
+    inner: IntoDType
 
-    def __init__(self, inner: DType | type[DType]) -> None:
+    def __init__(self, inner: IntoDType) -> None:
         self.inner = inner
 
     def __eq__(self, other: DType | type[DType]) -> bool:  # type: ignore[override]
@@ -657,11 +655,11 @@ class Array(NestedType):
         Array(Int32, shape=(2,))
     """
 
-    inner: DType | type[DType]
+    inner: IntoDType
     size: int
     shape: tuple[int, ...]
 
-    def __init__(self, inner: DType | type[DType], shape: int | tuple[int, ...]) -> None:
+    def __init__(self, inner: IntoDType, shape: int | tuple[int, ...]) -> None:
         inner_shape: tuple[int, ...] = inner.shape if isinstance(inner, Array) else ()
         if isinstance(shape, int):
             self.inner = inner
diff --git a/narwhals/expr.py b/narwhals/expr.py
index f3b1817e..fcd48f19 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -35,6 +35,7 @@ if TYPE_CHECKING:
     from narwhals.typing import (
         ClosedInterval,
         FillNullStrategy,
+        IntoDType,
         IntoExpr,
         NonNestedLiteral,
         NumericLiteral,
@@ -161,7 +162,7 @@ class Expr:
         """
         return function(self, *args, **kwargs)
 
-    def cast(self, dtype: DType | type[DType]) -> Self:
+    def cast(self, dtype: IntoDType) -> Self:
         """Redefine an object's data type.
 
         Arguments:
@@ -1015,7 +1016,7 @@ class Expr:
         old: Sequence[Any] | Mapping[Any, Any],
         new: Sequence[Any] | None = None,
         *,
-        return_dtype: DType | type[DType] | None = None,
+        return_dtype: IntoDType | None = None,
     ) -> Self:
         """Replace all values by different values.
 
@@ -2461,7 +2462,7 @@ class Expr:
             base: Given base, defaults to `e`
 
         Returns:
-            A new expression log values data.
+            A new expression.
 
         Examples:
             >>> import pyarrow as pa
@@ -2489,6 +2490,32 @@ class Expr:
             lambda plx: self._to_compliant_expr(plx).log(base=base)
         )
 
+    def exp(self) -> Self:
+        r"""Compute the exponent.
+
+        Returns:
+            A new expression.
+
+        Examples:
+            >>> import pyarrow as pa
+            >>> import narwhals as nw
+            >>> df_native = pa.table({"values": [-1, 0, 1]})
+            >>> df = nw.from_native(df_native)
+            >>> result = df.with_columns(exp=nw.col("values").exp())
+            >>> result
+            ┌────────────────────────────────────────────────┐
+            |               Narwhals DataFrame               |
+            |------------------------------------------------|
+            |pyarrow.Table                                   |
+            |values: int64                                   |
+            |exp: double                                     |
+            |----                                            |
+            |values: [[-1,0,1]]                              |
+            |exp: [[0.36787944117144233,1,2.718281828459045]]|
+            └────────────────────────────────────────────────┘
+        """
+        return self._with_elementwise_op(lambda plx: self._to_compliant_expr(plx).exp())
+
     @property
     def str(self) -> ExprStringNamespace[Self]:
         return ExprStringNamespace(self)
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 8f70aa95..b483236c 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -9,7 +9,6 @@ from narwhals._expression_parsing import (
     ExprKind,
     ExprMetadata,
     apply_n_ary_operation,
-    check_expressions_preserve_length,
     combine_metadata,
     extract_compliant,
     is_scalar_like,
@@ -32,7 +31,7 @@ from narwhals.dependencies import (
     is_numpy_array_2d,
     is_pyarrow_table,
 )
-from narwhals.exceptions import InvalidOperationError
+from narwhals.exceptions import InvalidOperationError, ShapeError
 from narwhals.expr import Expr
 from narwhals.translate import from_native, to_native
 
@@ -50,6 +49,7 @@ if TYPE_CHECKING:
     from narwhals.typing import (
         ConcatMethod,
         FrameT,
+        IntoDType,
         IntoExpr,
         IntoSeriesT,
         NativeFrame,
@@ -172,7 +172,7 @@ def concat(items: Iterable[FrameT], *, how: ConcatMethod = "vertical") -> FrameT
 def new_series(
     name: str,
     values: Any,
-    dtype: DType | type[DType] | None = None,
+    dtype: IntoDType | None = None,
     *,
     backend: ModuleType | Implementation | str | None = None,
     native_namespace: ModuleType | None = None,  # noqa: ARG001
@@ -226,7 +226,7 @@ def new_series(
 def _new_series_impl(
     name: str,
     values: Any,
-    dtype: DType | type[DType] | None = None,
+    dtype: IntoDType | None = None,
     *,
     backend: ModuleType | Implementation | str,
 ) -> Series[Any]:
@@ -1449,9 +1449,16 @@ def max_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
 class When:
     def __init__(self, *predicates: IntoExpr | Iterable[IntoExpr]) -> None:
         self._predicate = all_horizontal(*flatten(predicates))
-        check_expressions_preserve_length(self._predicate, function_name="when")
 
     def then(self, value: IntoExpr | NonNestedLiteral | _1DArray) -> Then:
+        kind = ExprKind.from_into_expr(value, str_as_lit=False)
+        if self._predicate._metadata.is_scalar_like and not kind.is_scalar_like:
+            msg = (
+                "If you pass a scalar-like predicate to `nw.when`, then "
+                "the `then` value must also be scalar-like."
+            )
+            raise ShapeError(msg)
+
         return Then(
             lambda plx: apply_n_ary_operation(
                 plx,
@@ -1473,11 +1480,21 @@ class When:
 class Then(Expr):
     def otherwise(self, value: IntoExpr | NonNestedLiteral | _1DArray) -> Expr:
         kind = ExprKind.from_into_expr(value, str_as_lit=False)
+        if self._metadata.is_scalar_like and not is_scalar_like(kind):
+            msg = (
+                "If you pass a scalar-like predicate to `nw.when`, then "
+                "the `otherwise` value must also be scalar-like."
+            )
+            raise ShapeError(msg)
 
         def func(plx: CompliantNamespace[Any, Any]) -> CompliantExpr[Any, Any]:
             compliant_expr = self._to_compliant_expr(plx)
             compliant_value = extract_compliant(plx, value, str_as_lit=False)
-            if is_scalar_like(kind) and is_compliant_expr(compliant_value):
+            if (
+                not self._metadata.is_scalar_like
+                and is_scalar_like(kind)
+                and is_compliant_expr(compliant_value)
+            ):
                 compliant_value = compliant_value.broadcast(kind)
             return compliant_expr.otherwise(compliant_value)  # type: ignore[attr-defined, no-any-return]
 
@@ -1581,7 +1598,7 @@ def all_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     )
 
 
-def lit(value: NonNestedLiteral, dtype: DType | type[DType] | None = None) -> Expr:
+def lit(value: NonNestedLiteral, dtype: IntoDType | None = None) -> Expr:
     """Return an expression representing a literal value.
 
     Arguments:
diff --git a/narwhals/series.py b/narwhals/series.py
index 73d5f137..92b784bb 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -48,6 +48,7 @@ if TYPE_CHECKING:
     from narwhals.typing import (
         ClosedInterval,
         FillNullStrategy,
+        IntoDType,
         NonNestedLiteral,
         NumericLiteral,
         RankMethod,
@@ -473,7 +474,7 @@ class Series(Generic[IntoSeriesT]):
             )
         )
 
-    def cast(self, dtype: DType | type[DType]) -> Self:
+    def cast(self, dtype: IntoDType) -> Self:
         """Cast between data types.
 
         Arguments:
@@ -1170,7 +1171,7 @@ class Series(Generic[IntoSeriesT]):
         old: Sequence[Any] | Mapping[Any, Any],
         new: Sequence[Any] | None = None,
         *,
-        return_dtype: DType | type[DType] | None = None,
+        return_dtype: IntoDType | None = None,
     ) -> Self:
         """Replace all values by different values.
 
@@ -2601,7 +2602,7 @@ class Series(Generic[IntoSeriesT]):
             base: Given base, defaults to `e`
 
         Returns:
-            A new expression log values data.
+            A new series.
 
         Examples:
             >>> import pandas as pd
@@ -2620,6 +2621,29 @@ class Series(Generic[IntoSeriesT]):
         """
         return self._with_compliant(self._compliant_series.log(base=base))
 
+    def exp(self) -> Self:
+        r"""Compute the exponent.
+
+        Returns:
+            A new series.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import narwhals as nw
+            >>> s_native = pd.Series([-1, 0, 1], name="a")
+            >>> s = nw.from_native(s_native, series_only=True)
+            >>> s.exp()
+            ┌───────────────────────┐
+            |    Narwhals Series    |
+            |-----------------------|
+            |0    0.367879          |
+            |1    1.000000          |
+            |2    2.718282          |
+            |Name: a, dtype: float64|
+            └───────────────────────┘
+        """
+        return self._with_compliant(self._compliant_series.exp())
+
     @property
     def str(self) -> SeriesStringNamespace[Self]:
         return SeriesStringNamespace(self)
diff --git a/narwhals/stable/v1/__init__.py b/narwhals/stable/v1/__init__.py
index 8470a0b8..3259be1c 100644
--- a/narwhals/stable/v1/__init__.py
+++ b/narwhals/stable/v1/__init__.py
@@ -82,6 +82,7 @@ if TYPE_CHECKING:
     from narwhals.dataframe import MultiColSelector, MultiIndexSelector
     from narwhals.dtypes import DType
     from narwhals.typing import (
+        IntoDType,
         IntoExpr,
         IntoFrame,
         IntoLazyFrameT,
@@ -1216,7 +1217,7 @@ def len() -> Expr:
     return _stableify(nw.len())
 
 
-def lit(value: NonNestedLiteral, dtype: DType | type[DType] | None = None) -> Expr:
+def lit(value: NonNestedLiteral, dtype: IntoDType | None = None) -> Expr:
     """Return an expression representing a literal value.
 
     Arguments:
@@ -1467,7 +1468,7 @@ def when(*predicates: IntoExpr | Iterable[IntoExpr]) -> When:
 def new_series(
     name: str,
     values: Any,
-    dtype: DType | type[DType] | None = None,
+    dtype: IntoDType | None = None,
     *,
     backend: ModuleType | Implementation | str | None = None,
     native_namespace: ModuleType | None = None,  # noqa: ARG001
diff --git a/narwhals/typing.py b/narwhals/typing.py
index 7a78405d..fe43757c 100644
--- a/narwhals/typing.py
+++ b/narwhals/typing.py
@@ -359,6 +359,38 @@ NonNestedLiteral: TypeAlias = (
 )
 PythonLiteral: TypeAlias = "NonNestedLiteral | list[Any] | tuple[Any, ...]"
 
+NonNestedDType: TypeAlias = "dtypes.NumericType | dtypes.TemporalType | dtypes.String | dtypes.Boolean | dtypes.Binary | dtypes.Categorical | dtypes.Unknown | dtypes.Object"
+"""Any Narwhals DType that does not have required arguments."""
+
+IntoDType: TypeAlias = "dtypes.DType | type[NonNestedDType]"
+"""Anything that can be converted into a Narwhals DType.
+
+Examples:
+    >>> import polars as pl
+    >>> import narwhals as nw
+    >>> df_native = pl.DataFrame({"a": [1, 2, 3], "b": [4.0, 5.0, 6.0]})
+    >>> df = nw.from_native(df_native)
+    >>> df.select(
+    ...     nw.col("a").cast(nw.Int32),
+    ...     nw.col("b").cast(nw.String()).str.split(".").cast(nw.List(nw.Int8)),
+    ... )
+    ┌──────────────────┐
+    |Narwhals DataFrame|
+    |------------------|
+    |shape: (3, 2)     |
+    |┌─────┬──────────┐|
+    |│ a   ┆ b        │|
+    |│ --- ┆ ---      │|
+    |│ i32 ┆ list[i8] │|
+    |╞═════╪══════════╡|
+    |│ 1   ┆ [4, 0]   │|
+    |│ 2   ┆ [5, 0]   │|
+    |│ 3   ┆ [6, 0]   │|
+    |└─────┴──────────┘|
+    └──────────────────┘
+"""
+
+
 # Annotations for `__getitem__` methods
 _T = TypeVar("_T")
 _Slice: TypeAlias = "slice[_T, Any, Any] | slice[Any, _T, Any] | slice[None, None, _T]"


Test patch:
diff --git a/tests/expr_and_series/cast_test.py b/tests/expr_and_series/cast_test.py
index d9d752a1..4df5e459 100644
--- a/tests/expr_and_series/cast_test.py
+++ b/tests/expr_and_series/cast_test.py
@@ -360,3 +360,51 @@ def test_cast_binary(request: pytest.FixtureRequest, constructor: Constructor) -
         "c": nw.String(),
     }
     assert_equal_data(result.select("c"), {"c": data["a"]})
+
+
+def test_cast_typing_invalid() -> None:
+    """**IMPORTANT**!
+
+    **Please don't parametrize these tests.**
+
+    They're checking we warn early when a cast will fail at runtime.
+    """
+    a = nw.col("a")
+
+    with pytest.raises(TypeError):
+        a.cast(nw.Field)  # type: ignore[arg-type]
+    with pytest.raises(TypeError):
+        a.cast(nw.Field("a", nw.Array))  # type: ignore[arg-type]
+
+    pytest.importorskip("polars")
+    import polars as pl
+
+    df = nw.from_native(pl.DataFrame({"a": [1]}))
+
+    # NOTE: If you're seeing this fail because they raise a more consistent error,
+    # feel free to update the types used
+    # See (https://github.com/narwhals-dev/narwhals/pull/2654#discussion_r2142263770)
+
+    with pytest.raises(AttributeError):
+        df.select(a.cast(nw.Struct))  # type: ignore[arg-type]
+
+    with pytest.raises(AttributeError):
+        df.select(a.cast(nw.List))  # type: ignore[arg-type]
+
+    with pytest.raises(AttributeError):
+        df.select(a.cast(nw.Array))  # type: ignore[arg-type]
+
+    with pytest.raises(ValueError):  # noqa: PT011
+        df.select(a.cast(nw.Enum))  # type: ignore[arg-type]
+
+    with pytest.raises(AttributeError):
+        df.select(a.cast(nw.Struct([nw.Field])))  # type: ignore[list-item]
+
+    with pytest.raises((ValueError, AttributeError)):
+        df.select(a.cast(nw.Struct({"a": nw.Int16, "b": nw.Enum})))  # type: ignore[dict-item]
+
+    with pytest.raises(AttributeError):
+        df.select(a.cast(nw.List(nw.Struct)))  # type: ignore[arg-type]
+
+    with pytest.raises(AttributeError):
+        df.select(a.cast(nw.Array(nw.List, 2)))  # type: ignore[arg-type]
diff --git a/tests/expr_and_series/exp_test.py b/tests/expr_and_series/exp_test.py
new file mode 100644
index 00000000..3cfcf1e1
--- /dev/null
+++ b/tests/expr_and_series/exp_test.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import pandas as pd
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import PANDAS_VERSION, Constructor, ConstructorEager, assert_equal_data
+
+data = {"a": [-1, 0, 1, 2, 4]}
+
+expected = [0.367879, 1.0, 2.718282, 7.389056, 54.59815]
+
+
+@pytest.mark.filterwarnings("ignore::RuntimeWarning")
+def test_exp_expr(constructor: Constructor) -> None:
+    df = nw.from_native(constructor(data))
+    result = df.select(nw.col("a").exp())
+    assert_equal_data(result, {"a": expected})
+
+
+@pytest.mark.filterwarnings("ignore::RuntimeWarning")
+def test_exp_series(constructor_eager: ConstructorEager) -> None:
+    series = nw.from_native(constructor_eager(data), eager_only=True)["a"]
+    result = series.exp()
+    assert_equal_data({"a": result}, {"a": expected})
+
+
+def test_exp_dtype_pandas() -> None:
+    s = pd.Series([1.0, 2.0], name="a", dtype="float32", index=[8, 7])
+    result = nw.from_native(s, series_only=True).exp().to_native()
+    expected = pd.Series([2.718282, 7.389056], name="a", dtype="float32", index=[8, 7])
+    pd.testing.assert_series_equal(result, expected)
+
+
+@pytest.mark.skipif(PANDAS_VERSION < (2, 0, 0), reason="nullable types require pandas2+")
+def test_exp_dtype_pandas_nullabe() -> None:
+    s = pd.Series([1.0, None, 2.0], name="a", dtype="Float32", index=[8, 7, 6])
+    result = nw.from_native(s, series_only=True).exp().to_native()
+    expected = pd.Series(
+        [2.718282, None, 7.389056], name="a", dtype="Float32", index=[8, 7, 6]
+    )
+    pd.testing.assert_series_equal(result, expected)
+
+
+@pytest.mark.skipif(PANDAS_VERSION < (2, 1, 0), reason="nullable types require pandas2+")
+def test_exp_dtype_pandas_pyarrow() -> None:
+    s = pd.Series([1.0, None, 2.0], name="a", dtype="Float32[pyarrow]", index=[8, 7, 6])
+    result = nw.from_native(s, series_only=True).exp().to_native()
+    expected = pd.Series(
+        [2.718282, None, 7.389056], name="a", dtype="Float32[pyarrow]", index=[8, 7, 6]
+    )
+    pd.testing.assert_series_equal(result, expected)
diff --git a/tests/expr_and_series/when_test.py b/tests/expr_and_series/when_test.py
index faa47155..53cd3ad4 100644
--- a/tests/expr_and_series/when_test.py
+++ b/tests/expr_and_series/when_test.py
@@ -120,6 +120,9 @@ def test_when_then_invalid(constructor: Constructor) -> None:
     with pytest.raises(ShapeError):
         df.select(nw.when(nw.col("a").sum() > 1).then("c"))
 
+    with pytest.raises(ShapeError):
+        df.select(nw.when(nw.col("a").sum() > 1).then(1).otherwise("c"))
+
 
 def test_when_then_otherwise_lit_str(constructor: Constructor) -> None:
     df = nw.from_native(constructor(data))
@@ -144,3 +147,58 @@ def test_when_then_otherwise_multi_output(constructor: Constructor) -> None:
         df.select(x1=nw.when(nw.all() > 1).then(nw.col("a", "b")))
     with pytest.raises(MultiOutputExpressionError):
         df.select(x1=nw.when(nw.all() > 1).then(nw.lit(1)).otherwise(nw.all()))
+
+
+@pytest.mark.parametrize(
+    ("condition", "then", "otherwise", "expected"),
+    [
+        (nw.col("a").sum() == 6, 100, None, [100]),
+        (nw.col("a").sum() == 6, 100, 200, [100]),
+        (nw.col("a").sum() == 6, nw.col("a").sum(), 200, [6]),
+        (nw.col("a").sum() == 6, 100, nw.col("b").sum(), [100]),
+        (nw.col("a").sum() == 6, nw.col("a").sum(), nw.col("b").sum(), [6]),
+        (nw.col("a").sum() == 5, 100, None, [None]),
+        (nw.col("a").sum() == 5, 100, 200, [200]),
+        (nw.col("a").sum() == 5, nw.col("a").sum(), 200, [200]),
+        (nw.col("a").sum() == 5, 100, nw.col("b").sum(), [15]),
+        (nw.col("a").sum() == 5, nw.col("a").sum(), nw.col("b").sum(), [15]),
+    ],
+)
+def test_when_then_otherwise_aggregate_select(
+    condition: nw.Expr,
+    then: nw.Expr | int,
+    otherwise: nw.Expr | int,
+    expected: list[int],
+    constructor: Constructor,
+) -> None:
+    df = nw.from_native(constructor({"a": [1, 2, 3], "b": [4, 5, 6]}))
+    result = df.select(a_when=nw.when(condition).then(then).otherwise(otherwise))
+    assert_equal_data(result, {"a_when": expected})
+
+
+@pytest.mark.parametrize(
+    ("condition", "then", "otherwise", "expected"),
+    [
+        (nw.col("a").sum() == 6, 100, None, [100, 100, 100]),
+        (nw.col("a").sum() == 6, 100, 200, [100, 100, 100]),
+        (nw.col("a").sum() == 6, nw.col("a").sum(), 200, [6, 6, 6]),
+        (nw.col("a").sum() == 6, 100, nw.col("b").sum(), [100, 100, 100]),
+        (nw.col("a").sum() == 6, nw.col("a").sum(), nw.col("b").sum(), [6, 6, 6]),
+        (nw.col("a").sum() == 5, 100, None, [None, None, None]),
+        (nw.col("a").sum() == 5, 100, 200, [200, 200, 200]),
+        (nw.col("a").sum() == 5, nw.col("a").sum(), 200, [200, 200, 200]),
+        (nw.col("a").sum() == 5, 100, nw.col("b").sum(), [15, 15, 15]),
+        (nw.col("a").sum() == 5, nw.col("a").sum(), nw.col("b").sum(), [15, 15, 15]),
+    ],
+)
+def test_when_then_otherwise_aggregate_with_columns(
+    condition: nw.Expr,
+    then: nw.Expr | int,
+    otherwise: nw.Expr | int,
+    expected: list[int],
+    constructor: Constructor,
+) -> None:
+    df = nw.from_native(constructor({"a": [1, 2, 3], "b": [4, 5, 6]}))
+    expr = nw.when(condition).then(then).otherwise(otherwise)
+    result = df.with_columns(a_when=expr)
+    assert_equal_data(result.select(nw.col("a_when")), {"a_when": expected})
diff --git a/tests/series_only/cast_test.py b/tests/series_only/cast_test.py
index 9432b99e..6311db96 100644
--- a/tests/series_only/cast_test.py
+++ b/tests/series_only/cast_test.py
@@ -125,7 +125,7 @@ def test_cast_to_enum_vmain(
     with pytest.raises(
         ValueError, match="Can not cast / initialize Enum without categories present"
     ):
-        df_nw.select(col_a.cast(nw.Enum))
+        df_nw.select(col_a.cast(nw.Enum))  # type: ignore[arg-type]
 
     df_nw = df_nw.select(col_a.cast(nw.Enum(["a", "b"])))
     assert df_nw.collect_schema() == {"a": nw.Enum(["a", "b"])}
diff --git a/tests/v1_test.py b/tests/v1_test.py
index 858bc9f0..af37a4ba 100644
--- a/tests/v1_test.py
+++ b/tests/v1_test.py
@@ -254,7 +254,7 @@ def test_cast_to_enum_v1(
         NotImplementedError,
         match="Converting to Enum is not supported in narwhals.stable.v1",
     ):
-        nw_v1.from_native(df_native).select(nw_v1.col("a").cast(nw_v1.Enum))
+        nw_v1.from_native(df_native).select(nw_v1.col("a").cast(nw_v1.Enum))  # type: ignore[arg-type]
 
 
 def test_v1_ordered_categorical_pandas() -> None: