Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

Interfaces

Interfaces define a set of method signatures that a struct type must implement. Harn uses Go-style implicit satisfaction: a struct satisfies an interface if its impl block contains all the required methods with compatible signatures. There is no implements keyword. Interfaces may also declare associated types.

Interface declaration

interface Displayable {
  fn display(self) -> string
}

interface Serializable {
  fn serialize(self) -> string
  fn byte_size(self) -> int
}

interface Collection {
  type Item
  fn get(self, index: int) -> Item
}

Each method signature lists parameters (the first must be self) and an optional return type. Associated types name implementation-defined types that methods can refer to. The body is omitted -- interfaces only declare the shape of the methods.

Implicit satisfaction

A struct satisfies an interface when its impl block has all the methods declared by the interface, with matching parameter counts:

struct Dog {
  name: string
}

impl Dog {
  fn display(self) -> string {
    return "Dog(${self.name})"
  }
}

Dog satisfies Displayable because it has a display(self) -> string method. No extra annotation is needed.

Using interfaces as type annotations

Interfaces can be used as parameter types. At compile time, the type checker verifies that any struct passed to such a parameter satisfies the interface:

fn show(item: Displayable) {
  log(item.display())
}

const d = Dog({name: "Rex"})
show(d)  // OK: Dog satisfies Displayable

Generic constraints with interfaces

Interfaces can be used as generic constraints via where clauses:

fn process<T>(item: T) where T: Displayable {
  log(item.display())
}

The type checker verifies at call sites that the concrete type passed for T satisfies Displayable. Passing a type that does not satisfy the constraint produces a compile-time error. Generic parameters must bind consistently across all arguments in the call, and container bindings such as list<T> propagate the concrete element type instead of collapsing to an unconstrained generic.

Bounds are full type expressions, so generic interfaces can be constrained with their concrete arguments:

interface Sink<in T> { fn accept(self, value: T) -> nil }
fn drain<S>(sink: S) where S: Sink<int> { sink.accept(1) }

A type parameter may carry more than one bound, written either as repeated clauses or additively -- the two forms are equivalent and T must satisfy every bound:

fn describe<T>(item: T) -> string where T: Named, T: Aged { ... }
fn describe<T>(item: T) -> string where T: Named + Aged { ... }

Inside the body, a method call on T resolves against all of its bounds: it is accepted when the method is declared on any bound interface, and a call to a method on none of them is flagged.

Subtyping and variance

Harn's subtype relation is polarity-aware: each compound type has a declared variance per slot that determines whether widening (e.g. int <: float) is allowed in that slot, prohibited entirely, or applied with the direction reversed.

Type parameters on user-defined generics may be marked with in or out:

type Reader<out T> = fn() -> T          // T appears only in output position
interface Sink<in T> { fn accept(v: T) -> int }
fn map<in A, out B>(value: A) -> B { ... }
MarkerMeaningWhere T may appear
out Tcovariantoutput positions only
in Tcontravariantinput positions only
(none)invariant (default)anywhere

Unannotated parameters default to invariant. This is strictly safer than implicit covariance — Box<int> does not flow into Box<float> unless Box declares out T and the body uses T only in covariant positions.

Built-in variance

ConstructorVariance
iter<T>covariant in T (read-only)
list<T>invariant in T (index assignment writes T)
dict<K, V>invariant in both K and V (index assignment writes V)
Result<T, E>covariant in both T and E
fn(P1, ...) -> Rparameters contravariant, return covariant
Shape { field: T, ... }covariant per field (width subtyping)

list and dict are invariant because index assignment can write through them, which makes them read-write positions. Methods such as appending are not a reason: they return a new collection and never modify the receiver, so they are covariant reads. See Binding mutability.

The numeric widening int <: float only applies in covariant positions. In invariant or contravariant positions it is suppressed — that is what makes list<int> to list<float> a type error.

Function subtyping

For an actual fn(A) -> R' to be a subtype of an expected fn(B) -> R, B must be a subtype of A (parameters are contravariant) and R' must be a subtype of R (return is covariant). A callback that accepts a wider input or produces a narrower output is always a valid substitute.

const wide = fn(x: float) { return 0 }
const cb: fn(int) -> int = wide   // OK: float-accepting closure stands in for int-accepting

const narrow = fn(x: int) { return 0 }
const bad: fn(float) -> int = narrow   // ERROR: narrow cannot accept the float a caller may pass

Declaration-site checking

When a type parameter is marked in or out, the declaration body is checked: each occurrence of the parameter must respect the declared variance. Mismatches are caught at definition time, not at each use:

type Box<out T> = fn(T) -> int
// ERROR: type parameter 'T' is declared 'out' (covariant) but appears
// in a contravariant position in type alias 'Box'