Skip to main content

Kind inheritance

A Telo.Definition can extends any kind — not only a Telo.Abstract, but a concrete kind with a real controller. This is ordinary single inheritance: the child reuses the parent's controller and behavior while presenting a friendlier schema and preset config.

The motivating case: an API client that is, for every runtime and type purpose, a preconfigured http-client Client — so it drops into anything that expects a Client, and any operation that expects one can point at it.

What a child inherits​

A child that extends a concrete kind and declares no own controllers: / template body inherits, by delegation:

  • The controller. The kernel resolves the controller-bearing ancestor, evaluates the child's base: mapping, calls the ancestor controller's create() with the mapped config, and returns that instance verbatim. The child instance therefore is a parent instance — it duck-types identically (e.g. it has the Client's .snapshot()), so consumers and reference injection need no awareness of the subclass.
  • The capability — immutably. Omit capability to inherit the ancestor's, or restate it identically. Declaring a different capability than an ancestor is a hard error (EXTENDS_CAPABILITY_MISMATCH). Inheritance never changes a kind's lifecycle role.
  • The schema. With base: present, the child's author-facing schema is its own schema — the parent's config fields become internal, set solely through base:, so the child genuinely narrows. Without base:, the child's schema is merge(parent, own) — a pure additive extension exposing the parent's config fields plus its own. A merge-form child forwards its whole config as the parent's, so a parent that closes its schema (additionalProperties: false) cannot accept an added field; declaring one is EXTENDS_CLOSED_PARENT_ADDS_FIELD, and the fix is a base: mapping.
  • The published reading. resources.<child> is the parent instance's snapshot(). Without base:, the fields the child declares that the parent did not are published over it, so a field the parent has never heard of reads back as resources.<child>.<field>. A redeclared inherited field is not: narrowing a field's schema (a description, a pattern, a widget hint) says nothing about publication, and the parent's snapshot() stays the sole authority on what a parent instance publishes — including its normalizations, its deliberate omissions and its redactions. Two values are withheld even for an own field, because neither is a reading: one whose slot is x-telo-eval: runtime (still an unevaluated expression when the reading is taken) and one holding a live resource instance. With base: the reading is the parent's alone — there the child's fields are construction inputs consumed by the mapping and do not exist on the instance.
  • The invocation contract — by replacement, not merge. inputType / outputType resolve to the nearest declaration along the chain: declare neither and the child presents its ancestor's signature unchanged; declare one and it fully replaces the ancestor's. Config merges because construction is additive; a call signature is not — merging a child's required inputs into its parent's would produce a union no caller could satisfy.

A child is Liskov-substitutable for every ancestor at any !ref slot, transitively — a GithubClient satisfies any slot typed x-telo-ref: Http.Client. Replacing the contract does not change that: extends decides which slots accept a resource, and never carried the dispatch contract. What a replaced contract does affect is wiring, per slot — see Presenting your own signature.

Presenting your own signature​

A child that inherits a controller may present a different call signature by declaring its own contract plus the mapping that bridges it back:

declaredrequired mappingwhat it does
inputTypeinputs:turns the child's signature into the parent's call
outputTyperesult:turns the parent's result into the child's

The mapping is not optional. The inherited controller understands only the shape it was written for, so a declaration without one would never be applied — telo check reports CONTRACT_MISSING_MAPPING.

kind: Telo.Definition
metadata: { name: Doubler }
extends: JS.Script
schema: { type: object, additionalProperties: false, properties: {} }
base:
code: |
function main({ n }) { return { out: n * 2 }; }
inputType:
kind: Telo.JsonSchema
schema:
type: object
required: [value]
properties: { value: { type: integer } }
outputType:
kind: Telo.JsonSchema
schema:
type: object
required: [doubled]
properties: { doubled: { type: integer } }
inputs:
n: !cel "inputs.value"
result:
doubled: !cel "result.out"

One dispatch now checks four boundaries where none were checked before: the child's inputs, the mapped values against the parent's contract, the parent's result, and the mapped result against the child's outputType.

Doubler stays wired wherever JS.Script is. Where the wiring rule bites is a slot whose consumer builds the arguments itself from the slot's declared kind alone — it would send the parent's shape to something that accepts the child's, so telo check rejects it (CONTRACT_SLOT_INPUTS_UNSATISFIABLE). At a slot that takes a paired author inputs: — a Run.Sequence step, an inline target step — the author supplies the arguments and the call site is checked instead.

base: — the super mapping​

base: is the "super(...)" of Telo. It is a top-level sibling on the definition: an object of CEL expressions over self (typed from the child's own schema), evaluated once to build the config passed to the inherited controller. It mirrors the existing inputs: / result: top-level-sibling factoring.

kind: Telo.Definition
metadata: { name: GithubClient }
extends: Http.Client # concrete parent; capability (Provider) inherited, immutable
schema:
type: object
required: [token]
properties:
token: { type: string } # with base:, this IS the author surface
base: # the base kind's config, derived from self
baseUrl: https://api.github.com
headers:
Authorization: !cel "'Bearer ' + self.token"
Accept: application/vnd.github+json

base: constructs the parent's config once. How that config then behaves is the parent controller's contract: fixed for kinds that consume config in create() (like Http.Client), or baked defaults the controller re-layers per call (like Http.Request, whose inputs a caller can still override).

An absent field stays absent through every level. A base: entry that forwards a field its author left out (accessToken: !cel "self.accessToken") contributes no key to the parent's config, so a parent whose own base: asks has(self.accessToken) sees false however many levels of base: lie between. When an expression in a base: mapping fails, the error names the definition and field that wrote it — in base.headers of GoogleAuth.GoogleClient (<source>) — not only the resource being created.

A base-form child's own fields are compile-eval, without annotation. They are construction inputs and nothing else — no controller ever sees them, base: reads them once at creation — so an expression written at one is evaluated there, against the startup scope (variables, secrets, ports, module, and the resources already published). That is the same posture Telo.Provider takes for the same reason, and it is why such a field needs no x-telo-eval: compile. The consequences are the ones compile-eval always has: the expression is type-checked at telo check, and a read of observed state (resources.x.status.…) is OBSERVED_STATE_IN_STARTUP_FIELD, because nothing has run yet. A field the child annotates x-telo-eval: runtime still wins.

A definition may not declare base: and a body. base: is read only on the inherited-controller path, and resources: / controllers: / a dispatch slot selects the template path instead — where base: is never evaluated. Declaring both is BASE_WITH_TEMPLATE_BODY at telo check and ERR_BASE_WITH_TEMPLATE_BODY at the kernel. A kind that stands up children of its own is a template; one that reshapes an existing controller's config is this.

Forwarding a reference field. When base: forwards a reference the child holds (e.g. client: !cel "self.client"), write it as a bare self.<field> access — nothing more. By the time base: runs, that field is a live resource instance, and a live instance cannot flow through a CEL expression (CEL's type checker rejects it). So !cel "self.enabled ? self.client : null", !cel "[self.client]", or any wrapping of the reference will fail. Keep reference forwarding and value-building CEL in separate base: fields — the same rule that governs self-derived references in a templated resources: body.

Worked example — a GitHub client library​

A small library that exposes a GithubClient plus one wrapped operation:

kind: Telo.Library
metadata:
name: github
namespace: acme
version: 0.1.0
imports:
Http: oci://ghcr.io/telorun/http-client@0.23.0
exports:
kinds:
- GithubClient
- SearchRepos
---
kind: Telo.Definition
metadata: { name: GithubClient }
extends: Http.Client
schema:
type: object
required: [token]
properties:
token: { type: string }
base:
baseUrl: https://api.github.com
headers:
Authorization: !cel "'Bearer ' + self.token"
Accept: application/vnd.github+json
---
# An operation that inherits Http.Request's controller. base: builds the parent
# request from THIS instance's config; the request fields are computed from `q`.
kind: Telo.Definition
metadata: { name: SearchRepos }
extends: Http.Request
schema:
type: object
required: [client, q]
properties:
client: { x-telo-ref: Http.Client } # re-declared to expose it; forwarded below
q: { type: string }
base:
client: !cel "self.client"
inputs:
url: /search/repositories
method: GET
query:
q: !cel "self.q"

Consuming it — the client is constructed from { token }, and because it is a Client, both the wrapped SearchRepos and a raw Http.Request can point at it:

kind: Telo.Application
metadata:
name: github-demo
version: 1.0.0
imports:
Gh: oci://ghcr.io/acme/github@0.1.0
Http: oci://ghcr.io/telorun/http-client@0.23.0
secrets:
ghToken: { env: GITHUB_TOKEN, type: string }
targets:
- invoke: !ref TopRepos
- invoke: !ref RateLimit
---
kind: Gh.GithubClient
metadata: { name: Api }
token: !cel "secrets.ghToken"
---
kind: Gh.SearchRepos # a wrapped operation
metadata: { name: TopRepos }
client: !ref Api # GithubClient into the inherited Http.Client slot
q: "stars:>10000"
---
kind: Http.Request # the escape hatch: a raw request for an unwrapped endpoint
metadata: { name: RateLimit }
client: !ref Api # same GithubClient, straight into Http.Request's client slot
inputs:
url: /rate_limit
method: GET

At runtime both instances are native instances of their parents: Api runs http-client's Client controller (from its base:-mapped config) and TopRepos runs the Request controller. Because Api is a Client, it drops into every client slot with no special handling.

Inheritance vs. templated composition​

Both let you define a kind without writing a controller — pick by intent:

Kind inheritance (extends + base:)Templated composition
IdentityThe instance is the parent (duck-types as it, .snapshot() etc.)The instance wraps internal children and forwards a dispatch method
ReuseOne parent controller, preset via base:Several existing kinds wired together
Substitutable at a parent's !ref slotYes, transitivelyNo — it is its own kind
Configbase: maps self onto the parent's config onceInternal resources: bodies are self-only

Reach for inheritance when your kind is one existing kind, specialized. Reach for templated composition when it is an assembly of several.

Telo.Abstract​

Telo.Abstract remains the non-instantiable base: it uniquely means no default implementation — must be extended. Use it for a contract with no default implementation (Sql.Connection, Codec.Encoder, Ai.Model, …); use plain extends of a concrete kind when you want to reuse an existing controller.