Skip to content

The HQL type system

Three mechanisms do the work, and they are easy to confuse because all three end with a value being used at a stronger type than it started with.

Mechanism Written The question it answers
Subtyping Card : Doc Which types stand in for which?
Generics Graph<N, E> What does this type leave unknown?
Refinement card.header.database : PostgresConfig What is known about this value?

Subtyping relates whole type domains. Generics abstract over them. Refinement concerns evidence about one value, whose representation never changes. They meet at exactly one place — a bound, T : Doc, where a generic unknown is restricted by the subtype relation — and that single meeting point is why one relation symbol is enough for the whole language.

There is no fourth mechanism for hiding anything. HQL has no field visibility: values are data and declared fields are queryable data, so a type exposes everything it declares. What a type can say is that a field may be absent — see fields.

Inhabitation, Subtyping, and Inheritance

Inhabitation relates a value or expression to a type.

alice : Person

means that alice is an inhabitant of Person, or equivalently that the expression alice has type Person. The notation x : T is the conventional type-theoretic typing judgment about a value. Set membership uses x ∈ A; it is a different judgment from assigning a type to an expression.

Subtyping relates one type to another type, and HQL writes it with the same :, under the type keyword:

type HmdCard : Card
type Card : Doc

A : B means that every value inhabiting A may also be treated as a value of B. Therefore:

card : HmdCard
type HmdCard : Card
type Card : Doc

implies:

card : Card
card : Doc

Subtyping is a relation between types; inhabitation is a relation between values and types. The two readings never compete for a reader, because the type keyword marks a type declaration, a binding annotation describes a value, and a where bound constrains a type parameter. A dedicated <: would carry no information the keyword does not already carry, so HQL spends one symbol rather than two.

Inheritance is a mechanism for defining one type in terms of another, usually by inheriting fields, operations, implementations, or declarations. It concerns how a type is constructed or defined.

Inheritance and subtyping are therefore distinct concepts. A language may make inheritance produce a subtype relationship, but this is a language-design choice rather than the definition of subtyping itself.

In HQL the two readings of : are told apart by context alone:

x : T             // x inhabits T
type A : B        // A is a subtype of B

: narrows

Card : Doc means that Card is a specialization of Doc: every card satisfies the contract of a document, while adding structure or stronger constraints of its own. The relation reads from the specific toward the general, and a value of the subtype is usable wherever the supertype is expected.

Nothing is extended or mutated by writing it. Doc is unchanged and unaware; declaring Card : Doc makes a claim about Card alone. That is why narrowing composes freely — Card : Doc and Person : Card give Person : Doc without either declaration being consulted about the other.

A type may narrow several at once

type X : {A, B, C}

The braces hold a set of supertypes: X declares each of A, B, and C as a parent and must satisfy every declared parent. They do not construct a union or intersection type. In supertype position the grammar reads {A, B} as a supertype set, while {a: A, b: B} describes fields.

The supertype set is normalized, so an ancestor reachable twice is present once: {Card, Doc} where Card : Doc already holds says no more than {Card}, and the redundancy is worth a warning rather than an error.

The case this form exists for is KnowledgeGraph, below. The scalars use it too: type Int : {Scalar, Orderable} says an integer is a leaf and has an order, two contracts that Bool and Card each satisfy only one of.

Two contracts never exclude each other, because a contract is satisfied by other types. Only two concrete types can share nothing, so {Int, String} is refused and {Scalar, Orderable} is not.

A union lists alternatives

type Data = union {
    Scalar,
    Seq<Data>,
    Map<String, Data>
}

A supertype set is a conjunction: X is every parent at once. A union is a choice: a value of Data is exactly one of the members. The two are written differently because they say different things — : says what a type narrows, and = union {…} says what a type is — and a declaration uses one or the other, never both.

Membership is how a type enters a union. Int never declares Data as a parent; it is a Data because it is a Scalar and Scalar is listed. A type may still narrow a union the ordinary way — type PostgresConfig : Data declares a tree whose shape is known — and that adds no fourth alternative: such a value is still a leaf, a sequence or a map, so whatever handles Data may rely on there being three cases.

A member may apply the union being declared. Seq<Data> inside Data is recursion through an argument, which is what makes a tree and always bottoms out in a leaf. A bare self-member would admit nothing new and is refused.

Membership runs one way: a Data is not thereby a Scalar. Going from the union to one of its members is learning something about a value, which is refinement's job, below.

Data is where a union is genuinely needed, and the bar stays there. A graph over documents and concepts is a generic, not a node union, and absence is Option<T>, not a union with a null.

What : is not

: states what a value is. Three other relations end with a value being used at a stronger type, and none of them is subtyping.

Capability is not ancestry. Graph : Renderable<Html> is tempting and wrong in the same way for every renderable type: it declares a parent whose implementation does not exist anywhere in the type graph, and the checker is left asking where render comes from. A capability is supplied for a type, by an extension registering render(Graph) -> Html, not inherited by it. HQL knows the type; the extension knows what can be done with it. A capability is therefore a registration the checker consults, not a type participating in :. That follows from HQL having no traits: there is nothing for Renderable<T> to be except a registration — see There are no traits below. What a parameterized registration looks like is still a design question; whether it is a type is not.

Orderable is the contrasting case and is genuine ancestry. A card's ordering key is its own name, not something an extension supplies, so Card : Orderable is an ordinary narrowing — see orderable.

Representation is not ancestry. A RelationCard represents a Relation; it is not one, and a Relation is not a Card. See below.

Projection is not ancestry. knowledge | graph yields a graph, and Knowledge is not thereby a supertype of Graph, nor Graph of Knowledge. A projection is a function — see graph-type. What a projection's result may do is declare both parents for itself, which is a claim about that one type and leaves the two domains peers; that case is below.

There are no traits

A trait is a host-language mechanism. An extension is written in Rust and may use traits internally, as any Rust program does; none of that reaches HQL's surface. "Rust does it this way" is never an argument for an HQL feature — see the stack scope note and hql/extensions.

Three different things had been collapsed into one mechanism, and separating them is why none of them needs a trait:

What is being said The mechanism
The value genuinely is the thing ordinary : — Card : Doc, Card : Orderable
Something can be done with the value, by someone else an extension registers render(Graph) -> Html
Several types happen to have the same fields a partial schema over an open Data tree — see fields

Typing is strict, not duck typing, and that is why no trait is needed: the checker asks about structure it can see. A record declares its shape and an incomplete literal fails at construction; a Data tree earns a stronger type by established structure, checked only where the evidence holds, and refinement never converts the value.

Absence is not part of this. Option is an ordinary sum type, not a trait — see option-type.

Abstract types of law

The : section above says what narrowing does to the subtype. This one says what stands on the other side of the relation.

An abstract type is defined by an algebraic or logical specification rather than by a representation. Seq<T> is the clearest case: its whole content is a law — a position exists for every element, and the positions are the natural numbers in order — and it prescribes no layout, no storage and no fields.

An abstract type is not a supertype. It exists on its own terms, declared and complete, whether or not anything ever narrows it. Supertype is a role conferred by someone else's declaration, not a kind of type: Seq<T> becomes a supertype of List<T> at the moment List<T> : Seq<T> is written, and Seq<T> is neither changed nor consulted by that. This is the narrowing section's point read from the other end — Doc is unchanged and unaware, and declaring Card : Doc makes a claim about Card alone.

What : asserts is that the laws are satisfied:

abstract type Seq<T> : Collection<T>  // positional laws
type List<T> : Seq<T>                 // a materialized carrier

In plain terms: Seq<T> is an abstract type of law, and A : B asserts that A satisfies the laws of B. It is why the checker is entitled to reject a narrowing it cannot support, and why Card : Orderable is genuine ancestry rather than a capability: the law is about the card's own name, so the card is in a position to satisfy it.

Such type is explicitly defined as abstract type of law, or axiomatized abstract type where more formality helps. The second is not the canonical name of a single type-theory construct, but every word in it maps cleanly to an established one.

Where the laws live

An abstract type reaches the repository in three parts, and no one of them is the type:

Part Where it sits
the declaration one short line in std/ — abstract type Seq<T> : Collection<T> in collections.hql
the law it stands for the comment beside that line, and the card arguing it
enforcement the Rust implementation under src/

A law is therefore implemented as defined where it is directly checkable, and described behaviorally — honored by the operations that produce and consume values of the type — where it is not. Seq's positional law is of the second kind: nothing verifies that positions exist, and everything that builds a Seq supplies them.

The binary carries the built-in declarations in Rust. Tests check the source declarations in std/ against those built-ins, while evaluation enforces the behavioral laws. See issue 0003.

What this decides about Seq and List

Seq<T> specifies positions without prescribing storage. List<T> is its concrete materialized carrier, represented by Value::List. Both are named by TypeConstructor and applied through TypeRef; abstract types such as Seq and Orderable have no separate runtime value variant.

Bindings infer their types from expressions. An annotation can request a broader view, such as observations : Collection<String> = sightings, but ordinary pipelines need none. Sorting accepts Collection<T> and produces List<T>, so cards | sort(by = c => c.title) directly orders the inferred card set. See the collection model.

<T> abstracts

Generics are written with angle brackets, in both positions: Seq<T> applies a type constructor and fn first<T>(xs: Seq<T>) : T? binds the parameter. There is no second spelling for binders. Square brackets stay value-level — [alice, bob] is a sequence and xs[0] is indexing — so an angle bracket always means the type world. The argument is in bind versus apply.

A generic parameter is an unknown type that a declaration is written against, so that one declaration serves a whole family:

type Edge<S, T> {
    source: S
    target: T
    data:   Data
}

type Graph<N, E>

S and T are not types with values of their own. They are positions, filled at each use — Edge<Card, Card>, Edge<Card, Concept> — and the declaration is checked once for all of them. This is what lets a graph over documents and a graph over concepts share one definition without a node union or a kind field.

Functions abstract the same way:

map<A, B>(f: A -> B) : Collection<A> -> Collection<B>

Nothing here is inferred away by the reader: map has one body, and A and B are chosen by the call site. Users are not expected to write those parameters at a call — they are inferred — but they exist in the signature.

Where the two cooperate: bounds

A bare parameter admits every type, which is usually more freedom than a function wants. A bound restricts it with the subtype relation:

fn titles<T>(docs: Collection<T>) : Collection<String>
where T : Doc

T is still unknown, and the body may now use everything Doc guarantees. The call site supplies Card, Person or Doc itself, and each is checked against the bound rather than against the body.

This is the whole of the interaction, and it is worth stating what it is not. Subtyping describes relationships between concrete type domains that exist whether or not any function is written. Generics let one function range over a family of such domains. A bound is where a function says which part of the lattice it needs, and : is the only vocabulary it needs to say so:

fn ordered<T>(xs: Collection<T>) : List<T>
where T : Orderable

A bound uses the same subtype relation as a declaration. The where context identifies the constrained parameter, just as type identifies a declaration, so both use :. The parser accepts type-declaration bounds such as type SortedMap<K, V> : OrderedMap<K, V> where K : Orderable; the function signatures above illustrate the same relation.

Variance is open. Card : Doc does not by itself decide whether Graph<Card, Link> : Graph<Doc, Edge> holds, and a function written over Graph<Doc, Edge> is useless if it does not. Covariance is defensible while graphs are immutable values and unsound as soon as one can be extended in place, so the answer depends on decisions about construction that have not been made — see graph-type.

Refinement establishes

Subtyping and generics are statements about types. Refinement is a statement about a value: its structure becomes progressively better known, and the value itself is never touched.

The starting point is Data, which is not Any. Data already says something strong — this is a valid structured tree, a leaf, a sequence of trees or a string-keyed map of trees — and everything that follows says stronger things about regions of that one tree.

authored YAML
  ↓
Data
  ↓  HMD knows some paths: tags, use, import, nav
Data refined by HMD
  ↓  extensions contribute schemas
Data refined by HMD and extension schemas
  ↓  the program tests existence and shape
locally refined further still

HMD's own specification reserves a small number of frontmatter keys and leaves every other key to the author while still requiring it to reach consumers, which is precisely a tree with a few known paths — see the language specification and the document model.

An extension refines by declaring what an existing region means:

import knowledge

card.metadata          : Data              // before
card.metadata          : KnowledgeMetadata // after
card.metadata.concepts : List<Concept>

The runtime value has not changed. What changed is what the checker can prove about it, which is the single most important property of the mechanism: an extension owns type knowledge about a tree, never the tree. Nothing is converted, copied or wrapped, so a header does not become a record on the way in and back again on the way out.

The same happens one layer down, on an ordinary authored header:

database:
  engine: postgres
  host: db.example.org
card.header.database : Data?               // before
card.header.database : PostgresConfig      // once the shape is established

The subtree never declared itself to be a PostgresConfig. It became usable as one because the required structure is there, and that is the intuition behind duck typing — if it has the shape, use it accordingly. HQL is stricter than that in one decisive respect:

duck typing
    it looks like a Duck, so attempt the operation

HQL
    the known structure proves this value satisfies Duck,
    and operations are checked against the proof

So the accurate description of the mechanism is open structural data with progressive type refinement: Data values keep their original tree representation while HMD semantics, extensions, schemas and predicates progressively establish stronger types for parts of that tree. The informal gloss — rigorous duck typing — is fair, and the technical name is the one to design against.

Two rules keep it honest.

Refinement never converts. The refined value is the same value; no as rewrites it, and losing the refinement loses only knowledge, never data.

Refinement must be checked. A type: WorksOn entry in a header is an author's claim, and typed RelationCard has to verify the contract. An unchecked narrowing would let any authored key mint a type, which is the same rule that stops relation: parent on an edge from making it a Parent — see card-type and graph-type. What a failed narrowing yields is open.

Cards, concepts and relations

The knowledge domain uses all three mechanisms at once, and the shape of its type family is the clearest illustration of what each one is for.

type Card : Doc                    // base representation: knowledge, written down

type ConceptCard : {Card, Concept}  // subject is a concept — a node
type RelationCard : Card           // subject is a relation — an edge

Card says only that a document represents knowledge. What it represents is the subtypes' business, and the asymmetry between them is the point.

ConceptCard declares a second parent because it genuinely has one. A concept card is a concept; it occupies a node position in a knowledge graph, and a function over Concept may take it. The supertype set normalizes: {Card, Concept, Doc} names Doc twice, since Card : Doc already holds, so {Card, Concept} is the form to write.

RelationCard declares no second parent, because it does not have one.

card.metadata.knowledge.type == Relation
card.metadata.relation : Relation<S, T>

Relation<S, T> : Card is never true. Relation is a first-class type in the knowledge vocabulary, beside Concept, Proposition and Evidence — a relationship with typed endpoints, its own identity, and evidence and provenance behind it. A relation acquires none of that from a document: it can be imported from another namespace, derived by a rule, or read out of a database, and in each case there is a relation and no card anywhere.

So a relation card represents a relation rather than being one, and the relation is reached by refinement through the card's knowledge layer. The discriminator is refinement too: metadata.knowledge.type is an authored entry, so it is a claim the checker must verify against a contract, exactly as relation: parent on an edge does not mint a Parent — see card-type and graph-type.

What Relation does narrow is the edge:

type Relation<S, T> : Edge<S, T>

An edge has a source, a target and data; a relation has all three and adds identity and evidence, so the narrowing is ordinary. A relation's identity is its own, which is what makes the knowledge graph a multigraph able to retain two conflicting claims about one pair — see graph-type.

The three mechanisms, side by side on this one family:

Written Mechanism What it establishes
ConceptCard : {Card, Concept} subtyping every concept card, for all time
Relation<S, T> : Edge<S, T> subtyping over a generic parent every relation, at every endpoint pairing
card.metadata.relation : Relation<S, T> refinement what is known about this card

The first two are declarations about type domains and hold whether or not any value exists. The third could fail for the next card along without disturbing either.

Optionality is part of the schema, not a fourth mechanism

A type may declare a field optional, and that is a statement about the shape of the data rather than about who may see it:

type HmdHeader {
    metadata? : Data
}

This sits with refinement rather than beside it. A partial schema supplies the paths a type knows about in advance and leaves the rest of the tree open, while refinement establishes more about one value at a use site. Neither closes Data, which is what lets the two compose. See fields for optionality, partial schemas and the distinction between a document's authored metadata and the metadata a card assembles.

KnowledgeGraph is both

type KnowledgeGraph : {Knowledge, Graph<Concept, Relation>}

This is the case the supertype set was invented for, and it is worth reading directly against RelationCard above. That type declares only Card, because a card representing a relation is not a relation and {Card, Edge<S, T>} would claim an ancestry that is not there. KnowledgeGraph is the opposite situation. A knowledge graph genuinely is a graph: concepts are its nodes and relations its edges. It genuinely is knowledge: it retains conflicting claims, provenance, evidence and constraints. Neither parent stands in for the other, and dropping either loses something the value really provides. The set is earned here and is not earned there, which is the whole test.

The two domains stay peers. Knowledge does not become a supertype of Graph, and the declaration says nothing about any type but this one. What it does say is demanding, and worth making explicit:

Both surfaces must be answerable. A KnowledgeGraph has to respond to nodes, edges and traversal and to claims, provenance, evidence and satisfaction. A value that answers only the first is a Graph<Concept, Relation> and should be typed as one.

Every declared supertype must hold. X : {A, B} names a set of two supertypes, and X must satisfy both contracts. The set records those parents; it does not introduce a separate compound type.

Projection can then be lossless. knowledge | graph has been described as a view that keeps adjacency and drops rationale. If its result is a KnowledgeGraph, nothing is dropped, and a genuinely lossy Graph<Concept, Relation> stays available as the weaker result for hosts that only draw pictures. Whether these are two operations or one whose result type is stronger than it used to be is open — see graph-type and the knowledge model.

Conflicts have to have an answer. Two concrete parents can disagree: a member with the same name and different types, or a shared ancestor reached two ways. {A, B} currently has no rule for that, and a type intended to be the language's central value cannot be the place where the rule is improvised.

Open questions

  • Whether narrowing is nominal or structural, and what a failed narrowing yields — an Option, a diagnostic, or a value carrying its failure.
  • The spelling of validation and refinement at the call site: validate<Person>(card), a where clause, or a predicate that the checker records.
  • Variance of generic parameters, which depends on whether constructed values are immutable.
  • How conflicting requirements from parents in a supertype set are diagnosed. Every declared parent must hold; the set notation and its normalization stay the same.
  • What a parameterized capability registration such as Renderable<T> looks like. That it is a registration rather than a type is decided; its shape is not.
  • How much refinement survives a boundary: a value passed to an extension, a cell, or a host and back.
  • Whether a declared-optional field is exactly Option<T>, and whether a subtype may change a field's optionality — tracked on fields.

See data-type for the tree being refined, fields for optional fields and partial schemas, card-type for Card and its two kinds, doc-type for the header, graph-type for Edge, Graph and variance, hql/knowledge for relations as knowledge, hql/collections for the generic collections and hql/design-status for what remains undecided.