skip to content

In Haystack, what does the @component decorator require of your class?

level: middleimportance: must knowfreq 78%

answer

  1. Decorate the class, declare the method
  2. Inputs live in the run signature
  3. Outputs are declared, not inferred
  4. Return a dict with matching keys
  5. Declared types drive connect-time validation

basics

~20 s

A Haystack component is a class decorated with @component that exposes a run() method. The typed parameters of run() become its inputs, and run() must return a dict whose keys match the names declared in @component.output_types on that method.

solid answer

~40 s

`@component` on the class registers it as something a pipeline can hold. The class must define `run()`, and that method carries the whole contract: its typed parameters are the component's inputs — a parameter with a default is an optional input — and it must return a `dict`. Outputs are not inferred from the return statement; you declare them with `@component.output_types(replies=list[str], meta=list[dict])` on `run()`, and the returned dict's keys must match those names. Those declared types are what the pipeline checks when you connect one component's output to another's input, so a mismatched wiring fails at build time rather than mid-run after you have paid for an LLM call. If the sockets are only known at construction time, call `component.set_input_types`/`component.set_output_types` inside `__init__` instead. `warm_up()`, `to_dict()`/`from_dict()` and an async `run_async()` are optional additions.

code

python · 13 lines
python
from haystack import component


@component
class WordCounter:
    @component.output_types(count=int, words=list[str])
    def run(self, text: str, lowercase: bool = True):
        normalized = text.lower() if lowercase else text
        words = normalized.split()
        return {"count": len(words), "words": words}


print(WordCounter().run(text="Haystack components are explicit"))

go deeper

for a junior

Memorise the shape: decorate the class with @component, write run(), decorate run() with @component.output_types, and return a dict whose keys match. Be able to write one from a blank editor.

for a middle

Explain that inputs come from the run signature with defaults marking them optional, that outputs are declared rather than inferred, and that the declared types are what the pipeline validates when components are connected.

for a senior

Show judgment about surface design: which knobs belong in init versus run(), why Any-typed sockets defeat validation, and how you unit-test a component in isolation before it ever enters a pipeline.

for a principal

Argue the trade-off the design encodes — a small amount of declared structure buys build-time validation, serialization and inspectability — and say when that tax is not worth paying compared with a convention-driven framework.

## The three parts of the contract Writing a Haystack component is deliberately small: decorate the class with `@component`, define `run()`, and declare `run()`'s outputs with `@component.output_types(...)`. Everything else — constructor arguments, helper methods, internal state — is ordinary Python. The framework asks for exactly enough structure to know what flows in and what flows out. ``` @component class X: @component.output_types(name=type, ...) def run(self, arg: type, optional: type = default): return {"name": ...} ``` ## Inputs come from the run signature The parameters of `run()` are the component's input sockets. Their names are the socket names, their annotations are the socket types, and whether they have a default decides whether the socket is mandatory. This is why Haystack components read as ordinary functions: there is no separate input schema object to keep in sync. A consequence people miss in interviews: `__init__` arguments are *configuration*, not inputs. A retriever's `top_k` set at construction is not something another component can feed it; only `run()` parameters are connectable. Deciding which knobs belong in `__init__` and which belong in `run()` is a real design choice — anything that should vary per request has to be a `run()` parameter. ## Outputs are declared, never inferred Python cannot tell you at import time which keys a dict return will contain, so Haystack makes you say it. `@component.output_types(count=int, words=list[str])` declares two output sockets; `run()` then returns `{"count": ..., "words": ...}`. Returning a bare value, a tuple, or a dataclass is a contract violation — the return must be a dict, and its keys are the declared names. If you return a key you never declared, nothing downstream can connect to it; it is not part of the component's surface. If you declare a key and sometimes omit it from the return, any consumer of that socket receives nothing for that run, which in a pipeline shows up as a downstream component never firing. ## Why the declared types matter The declared types are not documentation. When you connect two components, Haystack compares the producing socket's type against the consuming socket's type and refuses an incompatible edge. That check runs while the pipeline is being built — before any model is loaded, any index is queried, or any token is paid for. In a framework whose selling point is that pipelines are inspectable and testable, this is the mechanism doing the work: a whole class of "the LLM got a list where it wanted a string" bugs becomes a build error. It follows that vague annotations weaken the framework. Annotating an input as `Any` will connect to anything and validate nothing. ## Dynamic sockets Some components genuinely do not know their sockets until they are constructed — a router whose branches come from a config list, a passthrough parameterised by type. For those, call `component.set_input_types(self, **types)` or `component.set_output_types(self, **types)` inside `__init__`. The decorator form is preferred whenever the shape is static, because it is visible in the source and in generated docs; the dynamic form hides the surface behind constructor logic. ## The optional lifecycle hooks Beyond the required core, a component may implement: - `warm_up()` — load models and other heavy resources, keeping construction cheap. The framework calls it before the component's first execution. - `to_dict()` / `from_dict()` — control serialization when the default init-parameter mapping cannot round-trip your arguments. - `run_async()` — an asynchronous twin of `run()` with the same sockets, used when the pipeline is driven asynchronously. None of these are required, and a component that is pure computation over JSON-friendly arguments needs none of them. ## What a strong answer adds The question is nominally "what does the decorator require", but the answer interviewers reward explains *why* the surface is declared rather than inferred: because the pipeline is a typed graph, and a typed graph can be validated, serialized to YAML, drawn, and diffed in review. Convenience frameworks infer wiring and fail at run time; Haystack asks for four lines of declaration and fails at wiring time instead. Stating that trade-off out loud is the difference between reciting a decorator and explaining a design.

  • What breaks if run() returns a key you never declared in output_types?
    That key is invisible to the pipeline: it is not a socket, so nothing can be connected to it and no downstream component ever sees the value. The inverse hurts more — declaring a socket and then omitting it from the returned dict leaves consumers with no input for that run, so a downstream component simply never executes and the pipeline finishes with a quietly incomplete result.
  • How would you write a component whose outputs are not known until __init__?
    Call `component.set_output_types(self, **types)` inside `__init__` — and `component.set_input_types` for the input side — instead of using the decorator. Reserve it for genuinely dynamic surfaces such as a configurable router, because it moves the component's contract out of the readable method signature and into constructor logic where reviewers and generated docs will not see it.
  • Why put a knob in __init__ rather than as a run() parameter?
    `__init__` arguments are configuration: they are captured in the component's serialized `init_parameters`, are fixed for the life of the instance, and cannot be fed by another component. `run()` parameters are per-request data and are connectable. Anything that should vary between requests, or be produced by an upstream component, has to be a `run()` parameter.

saying these in an interview costs you the question

  • Says run() may return a tuple or a bare value
  • Thinks output names are inferred from the return annotation
  • Puts @component.output_types on the class instead of run()
  • Assumes untyped or Any-typed sockets are still validated
  • Confuses constructor configuration with connectable inputs

context