Function Types¶
Function types represent the signature of callable values, including lambdas, method references, and delegate instances. They are used in type annotations for parameters, return types, fields, and type aliases.
Syntax¶
The function type syntax uses an arrow notation:
Examples¶
# No parameters, returns int
counter: () -> int
# Single parameter
processor: (str) -> int
# Multiple parameters
calculator: (int, int) -> int
# Returns None (void function)
callback: (str) -> None
# Nullable function type
handler: ((Event) -> None)?
# Function returning a function
factory: (str) -> ((int) -> bool)
# Generic function types (in type aliases)
type Callback[T] = (T) -> None
type Predicate[T] = (T) -> bool
type Transform[T, U] = (T) -> U
Parameter Names¶
Not yet implemented. The parser currently only supports unnamed parameter types in function type annotations (e.g.,
(int, str) -> bool). The named parameter syntax shown below (e.g.,(count: int, message: str) -> bool) is planned but not yet supported. Attempting to use named parameters in function type annotations will result in a parse error.
Parameter names are optional in function type annotations. When provided, they serve as documentation only and are not part of the type signature:
# Without parameter names (currently the only supported form)
handler: (int, str) -> bool
# With parameter names (for documentation) — NOT YET IMPLEMENTED
handler: (count: int, message: str) -> bool
# Both forms are equivalent types
# The names do not affect type compatibility
Note: Parameter names in function types do not create named parameter requirements at call sites. They are purely for readability and documentation.
Calls through a function-typed value are positional-only. A function type carries no
parameter names (a .NET delegate has none), so a keyword argument on a value of function type —
a (T) -> R parameter, a lambda, a def stored in a variable, a bound method stored as a
value — cannot bind and is refused with SPY0279, which names the argument to pass positionally.
The rule is about the callee kind, not the spelling: calling a def or a method directly binds
keywords as usual, and redundant parentheses around such a callee ((obj.method)(k=1)) do not
change that.
def apply(f: (int, int) -> int, x: int, y: int) -> int:
return f(x, y) # OK — positional
# return f(a=x, b=y) # ERROR SPY0279 — keyword arguments are not supported when
# calling a function-typed value; pass 'a' positionally
def add(a: int, b: int) -> int:
return a + b
def main() -> None:
print(apply(add, 1, 2))
print(add(a=1, b=2)) # OK — a def call binds by name
print((add)(a=1, b=2)) # OK — the same call, parenthesized
g = add
# g(a=1, b=2) # ERROR SPY0279 — `g` is a function-typed value
# NOT YET IMPLEMENTED — named parameters in function type aliases
type EventHandler = (sender: object, args: EventArgs) -> None
# All of these work - names are not enforced
def my_handler(s: object, a: EventArgs) -> None:
pass
def another_handler(obj: object, event_args: EventArgs) -> None:
pass
h: EventHandler = my_handler # OK
h = another_handler # OK
Function Types with None Return Type¶
Function types that indicate a function with no return value, i.e. -> None
must have the return type annotation -> None indicated. While it is true
that function definitions may omit this return type annotation if it is
-> None, function types of this sort on the other hand require it
for parsing/syntactic reasons.
No Optional Parameters in Function Type Annotations¶
Function type annotations cannot specify optional parameters (parameters with default values). All parameters in a function type annotation are required. Note that lambda expressions can have default parameters — this restriction applies only to the type annotation syntax:
# ❌ Invalid - cannot specify defaults in function type annotations
type BadCallback = (x: int, y: int = 0) -> int
# ✅ Valid - all parameters required
type GoodCallback = (int, int) -> int
# To accept functions with optional params, use the required-only signature
def process(callback: (int) -> int) -> int:
return callback(42)
# Functions with more parameters than required cannot be assigned
def add(x: int, y: int = 0) -> int:
return x + y
process(add) # ERROR: (int, int) -> int is not assignable to (int) -> int
# But you can wrap them in a lambda
process(lambda x: add(x)) # OK
Rationale: Function type annotations describe a calling convention -- what the caller must provide. Since the caller cannot know about default values, function type annotations represent the minimal required signature. This aligns with C# delegate semantics where all parameters are required.
Note: This restriction applies to the type annotation syntax
(int, int) -> int, not to lambda definitions themselves. Lambdas can have default parameter values in their definitions -- see Lambda Expressions.
Function Type Compatibility¶
A function type A is assignable to function type B if:
1. They have the same number of parameters
2. Parameter types are compatible in either direction (A's param assignable to B's, or B's param assignable to A's)
3. Return types are covariant (A's return type assignable to B's)
Design note: Parameter compatibility uses bidirectional assignability rather than strict contravariance. This is a deliberate choice that simplifies common callback patterns while remaining sound for the cases Sharpy supports (no mutable function-type containers that would expose the unsoundness). Strict contravariant checking is enforced at declaration sites via
VarianceValidatorfor interface and delegate type parameters -- see Generic Variance.
# Covariance in return types
type AnimalFactory = () -> Animal
type DogFactory = () -> Dog
dog_factory: DogFactory = lambda: Dog()
animal_factory: AnimalFactory = dog_factory # OK: Dog is subtype of Animal
# Bidirectional parameter compatibility
type AnimalHandler = (Animal) -> None
type DogHandler = (Dog) -> None
animal_handler: AnimalHandler = lambda a: print(a)
dog_handler: DogHandler = animal_handler # OK: Animal assignable to Dog's position (bidirectional)
Using Function Types¶
As parameter types:
def apply(value: int, transform: (int) -> int) -> int:
return transform(value)
result = apply(5, lambda x: x * 2) # 10
As return types:
def make_multiplier(factor: int) -> (int) -> int:
return lambda x: x * factor
doubler = make_multiplier(2)
print(doubler(5)) # 10
As field types:
class Button:
on_click: ((Button) -> None)?
def __init__(self):
self.on_click = None
def click(self) -> None:
if self.on_click is not None:
self.on_click(self)
In collections:
Constructor References¶
A bare type name used as a value — f = int, f = dict, f = MyClass — is a constructor reference. It is a legitimate value, but like a C# method group it has no natural type of its own: int, str, float and bool each name an overload set; list, dict and set are generic; and a user class may declare several constructors. Nothing in the reference itself says which signature was meant, so the signature has to come from the context. There are exactly two outcomes: the context supplies one, or the reference is refused.
1. Pinned against an expected function type. Wherever a signature is available — an annotated target, a declared return type, or the parameter it is passed to — the reference binds that signature:
g: (str) -> int = int
print(g("42")) # 42
h: () -> dict[str, int] = dict
d = h()
d["a"] = 1
def make_parser() -> (str) -> int:
return int # the declared return type pins it
def apply(fn: (str) -> int, s: str) -> int:
return fn(s)
print(apply(int, "5")) # the parameter type pins it
The collection families pin to their empty constructor (() -> list[int]) or their copy constructor ((list[int]) -> list[int]).
A user class or struct pins against its declared constructors:
class Point:
x: int
def __init__(self, x: int):
self.x = x
mk: (int) -> Point = Point
print(mk(7).x) # 7
print(list(map(Point, [1, 2, 3]))) # the class name as a factory, like map(int, xs)
A class with no declared __init__ offers exactly the zero-argument shape, and a generic class takes its type arguments from the target exactly as the collections do — from the target, never from the reference:
class Box[T]:
value: T
def __init__(self, value: T):
self.value = value
mb: (int) -> Box[int] = Box # the BARE name; the target supplies T
print(mb(3).value) # 3
Writing the type arguments on the reference instead (f = Box[int]) is a type reference, not a value, and is refused with SPY0339.
2. Otherwise, an error (SPY0342). A reference the context supplies no signature for has no way to acquire one, so it is refused where it is written rather than compiled into something arbitrary:
f = int # SPY0342 — a plain binding supplies no target type
p_maker = Point # SPY0342 — the same for a user class
xs = [int, str] # SPY0342 — a list element supplies no target type
f = int if c else str # SPY0342 — a conditional supplies none either
print(list(map(Box, ns))) # SPY0342 — a GENERIC class in an argument whose
# parameter type supplies no type arguments
Annotate the target with a function type, call the type directly, or wrap the construction in a lambda that fixes the signature yourself.
A lambda is what to reach for when the factory has to be a value that varies at run time. Unlike a constructor reference it has a runtime representation, so it can be rebound and captured, and it observes the ordinary rules:
make: (int) -> Point = lambda v: Point(v)
f: () -> Animal = lambda: Cat()
if flag:
f = lambda: Dog()
print(f().speak()) # woof — the branch runs, as in Python
Retired. There used to be a third outcome: a binding with no signature available became a call-only alias, and each call through it resolved like a call of the type itself (
f = int; f("3")). It was untyped by design — it had no runtime representation, emitted no C#, and was resolved where it was read rather than where it was written, which made it a compile-time macro rather than a value. With overloaded constructors, which signature was meant was unknowable until each call, and a closure capturing one silently diverged from Python. C# has no constructor values at all, and Java'sPoint::newis legal only in target-typed positions — which is rule 1 above. Rewrite an alias as a pinned reference where the signature is known, or as a lambda where it is not (#1248).
A type that cannot be constructed is not a constructor reference (SPY0346). An interface, an enum, a union type name, a delegate type and an abstract class have no construction, so there is nothing for the name to denote:
s = IShape # SPY0346 — an interface has no constructor
e = Color # SPY0346 — a member is the value you want: Color.RED
This is a different failure from SPY0342, which means this position supplies no signature to select among the ones the type offers — and that presumes it offers some. Constructing one directly is refused for the same reason (SPY0280).
Writing a type name where it names a type rather than a value is unaffected, for builtin and user names alike: a static-member receiver (int.parse(s), dict.fromkeys(ks), Point.of(v)), a type-test type argument (isinstance(x, int), isinstance(x, Point)), and a type argument (Box[int], Box[Point]) are all type positions, not constructor references.
Implementation
- ✅ Native — the conversion families emit the Sharpy.Builtins.X method group, so C#'s own method-group conversion binds the overload against the pinned delegate type; the collection and user-type families emit a constructor lambda.
- ✅ User classes and structs (#1211), pinning against their declared constructors, including generic classes whose type arguments come from the target.
- ✅ Interfaces, enums, unions, delegate types and abstract classes are not constructible and are not constructor references: SPY0346 as a value, SPY0280 constructed directly (#1250, #1271).
- ✅ A user class in a direct call argument that does not pin draws SPY0342 naming its declared constructors, never an internal error (#1249).
- ✅ The call-only alias is retired, which removes the conditional-rebind defect by construction rather than by correcting it (#1248). The lambda form above is the replacement and is flow-correct.
- ⚠️ A generic type reference that carries its own type arguments (f = Box[int]) is a type reference, not a value, and stays refused with SPY0339.
- ⚠️ A constructor reference pins only against a target whose return type names the class itself; a base-typed target does not pin, though the equivalent lambda converts — see #1270.
- ⚠️ A few builtin types construct but have no constructor-reference form (object, bytes, decimal, frozenset, frozendict, Iterator, the view types). A reference to one draws SPY0342 rather than pinning — see #1272.
C# Mapping¶
Function types map to C# delegate types:
| Sharpy | C# |
|---|---|
() -> None |
Action |
(T) -> None |
Action<T> |
(T1, T2) -> None |
Action<T1, T2> |
() -> R |
Func<R> |
(T) -> R |
Func<T, R> |
(T1, T2) -> R |
Func<T1, T2, R> |
Implementation
- ✅ Native - Maps to System.Action<> and System.Func<> delegates.
Delegates vs Function Types¶
Function types ((T) -> R) and delegates (delegate F(x: T) -> R) both represent callable signatures, but serve different purposes:
- Function types are anonymous and map to
Func<>/Action<>. Use them for internal callbacks, higher-order function parameters, andtypealiases. - Delegates are named C# types. Use them when you need variance annotations (
in/out), event handler types, or a distinct named type for .NET interop.
# Function type via type alias — preferred for internal use
type Transform[T, U] = (T) -> U
# Delegate — use when variance or events require it
delegate Producer[out T]() -> T
When in doubt, start with a function type. Promote to a delegate only when you need a feature that function types cannot provide. See Delegates — When to use delegates and Type Aliases.
Default Parameter Erasure (Axiom 1 deviation)¶
In Python, passing a function with defaults through a variable preserves the defaults. In Sharpy, converting a function or lambda with default parameters to a function type erases the defaults — .NET delegates (Func<>, Action<>) do not carry default values. The compiler emits SPY0486 (warning) at the conversion site and SPY0277 (error) if a caller omits arguments through the delegate: