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) {
  harness.obs.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 {
  harness.obs.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:

// T appears only in output position
type Reader<out T> = fn() -> T
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>covariant in T (value semantics prevent shared mutable aliases)
tuple<T0, ...>covariant at each fixed position
dict<K, V>invariant in K, covariant in V
Result<T, E>covariant in both T and E
fn(P1, ...) -> Rparameters contravariant, return covariant
Shape { field: T, ... }covariant per field (width subtyping)

Harn collections have value semantics: binding or passing a collection creates an independent value, so a write cannot mutate a narrower alias retained by the caller. Lists and tuple positions are therefore covariant, as is a dict's value type. Dict keys remain invariant because key widening changes the lookup domain. See Binding mutability.

The numeric widening int <: float only applies in covariant positions. In invariant or contravariant positions it is suppressed.

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 }
// OK: float-accepting closure stands in for int-accepting
const cb: fn(int) -> int = wide

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

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'