Create your own
Lesson illustration

Designing Clear, Typed Angular Component Contracts

Welcome. This course is built for senior full-stack interview readiness: concise mental models first, then the mechanics and trade-offs that interviewers use for follow-up questions. This opening Angular module updates your Angular 10 experience to modern Angular 22 patterns, beginning with the boundary that determines whether a component remains understandable as the feature grows: its contract.

A component contract answers four questions:

  1. What data does this component need to render?
  2. What user intentions can it report?
  3. Who owns each piece of state?
  4. What is explicitly outside this component’s responsibility?

In this lesson, you will design that contract using standalone components, signal-based input(), typed output(), and, where appropriate, model().


Start with responsibility, not decorators

A common Angular 10 pattern was to put data loading, permission checks, mapping, modal coordination, API calls, and rendering into one component. It can work initially, but it creates a poor interview answer because no clear boundary exists: every change risks affecting everything else.

For a production feature such as Channel Fund Management, separate responsibilities deliberately.

Consider a page that displays an allocation and lets the user edit or remove it.

ConcernBest ownerWhy
Fetching allocation dataFeature/page component or facade/storeIt coordinates route data, API calls, loading, errors, and retries.
Canonical allocation stateParent feature stateMultiple child components may need it; it represents business state.
Rendering one allocation cardLeaf/presentational componentIt receives a snapshot and renders it predictably.
A button click such as “Edit”Leaf component emits an intentThe leaf knows that a user requested editing, not how navigation or persistence works.
Modal visibility / selected allocation IDUsually feature parentIt coordinates siblings and workflow.
Temporary UI state such as a collapsed details panelLeaf componentIt affects only the component’s presentation.
Currency formattingPipe or formatting utilityIt is reusable and independent of feature orchestration.

The names “smart” and “dumb” components are less useful than an ownership test:

If this component disappeared, would the business workflow, server interaction, routing, or shared feature state still make sense elsewhere?

If yes, that logic usually should not live in a reusable leaf component.

A leaf component can still have meaningful logic. It can calculate display values, translate DOM events into typed domain intents, manage focus or expanded state, and enforce UI-level constraints. The point is not minimal code; it is cohesive code.


The contract: data enters through typed inputs; intent leaves through typed outputs

In Angular 22, the recommended API for new components is signal-based input() and output(). The older @Input() and @Output() APIs remain supported, so you can modernize incrementally rather than rewrite working Angular 10 features.

Here is a focused allocation card. It has no HTTP dependency, no route dependency, and no hidden knowledge of the parent feature.

import {
  ChangeDetectionStrategy,
  Component,
  booleanAttribute,
  computed,
  input,
  output,
} from '@angular/core';

export interface FundAllocation {
  readonly id: string;
  readonly partnerName: string;
  readonly approvedAmount: number;
  readonly spentAmount: number;
  readonly status: 'open' | 'locked';
}

export interface AllocationEditRequested {
  readonly allocationId: string;
}

export interface AllocationRemoveRequested {
  readonly allocationId: string;
}

@Component({
  selector: 'bm-fund-allocation-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article class="allocation-card">
      <h3>{{ allocation().partnerName }}</h3>

      <p>
        Remaining: {{ remainingAmount() }}
      </p>

      <p>
        Status: {{ allocation().status }}
      </p>

      <button
        type="button"
        [disabled]="!canRequestEdit()"
        (click)="requestEdit()">
        Edit allocation
      </button>

      <button
        type="button"
        (click)="requestRemove()">
        Remove allocation
      </button>
    </article>
  `,
})
export class FundAllocationCardComponent {
  readonly allocation = input.required<FundAllocation>();

  readonly canEdit = input(true, {
    transform: booleanAttribute,
  });

  readonly editRequested = output<AllocationEditRequested>();
  readonly removeRequested = output<AllocationRemoveRequested>();

  protected readonly remainingAmount = computed(
    () => this.allocation().approvedAmount - this.allocation().spentAmount,
  );

  protected readonly canRequestEdit = computed(
    () => this.canEdit() && this.allocation().status === 'open',
  );

  protected requestEdit(): void {
    this.editRequested.emit({
      allocationId: this.allocation().id,
    });
  }

  protected requestRemove(): void {
    this.removeRequested.emit({
      allocationId: this.allocation().id,
    });
  }
}

This contract is intentionally small:

  • allocation is required because the card cannot render meaningfully without it.
  • canEdit is optional because the default behavior is clear: editing is enabled unless the parent says otherwise.
  • editRequested and removeRequested describe user intent, not implementation detail.
  • The card owns derived display state, such as remainingAmount.
  • The parent owns the workflow triggered by those events.

A parent might use it as follows:

<bm-fund-allocation-card
  [allocation]="selectedAllocation()"
  [canEdit]="permissions().canManageFunds"
  (editRequested)="openAllocationEditor($event)"
  (removeRequested)="confirmAllocationRemoval($event)" />

Notice what is not in the child API:

  • an HttpClient call to delete the allocation;
  • a dependency on the router to navigate to the editor;
  • a raw isAdmin input when the actual decision is “may this user edit this allocation?”;
  • an output such as buttonClicked.

Those would expose either implementation details or ambiguous information. A good contract preserves the meaning of the interaction.


Modern inputs: required, optional, read-only, and transformed

Signal-based inputs and the output function

Watch “Signal-based inputs and the output function” by Brian Treese for a compact bridge from decorator-based Angular 10 components to signal-based inputs, derived state, and typed outputs.

Watch input migration to see a required decorator input become an input.required() signal and derived fields become computed() values. Then watch typed outputs for the equivalent modern output() pattern. Focus on the change in reading syntax: an input signal is read with parentheses, such as player(), rather than accessed as a mutable field.

An input is a declaration of what the parent is allowed and expected to provide. In a signal-based component, input() returns an InputSignal.

readonly pageSize = input(25);
readonly filter = input<string>();
readonly allocation = input.required<FundAllocation>();

These mean different things:

DeclarationContract meaningType when read
input(25)Optional input with a meaningful defaultnumber
input<string>()Optional input with no defaultstring | undefined
input.required<FundAllocation>()The parent must supply itFundAllocation

input.required<T>() improves precision compared with the Angular 10 pattern:

@Input() allocation!: FundAllocation;

The definite-assignment assertion (!) tells TypeScript to trust you, even if the template omitted the binding. A required signal input instead lets Angular report a template build error when the component is used without that required input.

There are two important limits:

  1. Required inputs are not runtime business validation. API payloads, route parameters, and user input still need validation at their actual trust boundaries.
  2. An input signal is read-only from the child’s perspective, but an object passed through it is not automatically immutable. readonly protects the property reference in TypeScript; it does not deep-freeze allocation().

Therefore, do not mutate an object received through an input:

// Avoid: child silently changes parent-owned business state.
this.allocation().spentAmount = 500;

Instead, the child emits an intent. The parent creates the updated immutable state, validates it, and persists it if appropriate. This makes state transitions traceable and works naturally with OnPush and signal-based rendering, which you will examine in a later lesson.

Input transforms are boundary normalization, not business logic

The booleanAttribute transform is useful when a template may use an HTML-style boolean attribute:

<bm-fund-allocation-card canEdit />

It converts the attribute form into a boolean. Input transforms should be:

  • pure;
  • statically defined so Angular can analyze them at build time;
  • inexpensive;
  • limited to normalization or coercion.

Do not use a transform to call a service, mutate global state, fetch permissions, or perform expensive calculations. Also be cautious with numberAttribute: invalid input becomes NaN, which is often a signal to validate at a more explicit boundary rather than silently continue.

Accepting data with input properties • Angular

Read Angular’s official input guide to make the Angular 10 to Angular 22 API mapping precise: optional and required inputs, signal reads, transforms, model inputs, and continued support for decorators.

In the opening material and the “Reading inputs” section, read signal input basics. Focus on why an input without a default can be undefined, why input.required has a non-optional type, and why Angular requires these declarations in property initializers. In “Required inputs,” read the required contract. Treat this as template-level contract enforcement, not replacement for runtime validation. In “Input transforms,” read transform rules, including the requirement that transforms are pure and statically analyzable. Continue through the built-in transformations and note the NaN behavior of numberAttribute. In “Model inputs,” read the ownership distinction, then read implicit change events. Finish with the “Declaring inputs with the @Input decorator” section, especially the compatibility bridge.


Outputs should report meaningful events, not expose the child’s internals

An output is part of your public component API. Its name and payload should let a caller understand what happened without reading the child implementation.

Prefer:

readonly saved = output<SaveAllocationRequest>();
readonly removeRequested = output<AllocationRemoveRequested>();
readonly selectionChanged = output<PartnerSelection>();

Avoid vague or implementation-oriented names:

readonly clicked = output();
readonly onSave = output();
readonly emitData = output<any>();
readonly buttonTwoPressed = output();

A useful convention is to name outputs as events or requests:

  • closed
  • selectionChanged
  • saveRequested
  • deleteRequested
  • submitted

Whether you choose a past-tense event (saved) or an intention (saveRequested) should reflect the truth. If the child only observes a click and the parent performs the save, saveRequested is accurate. Naming it saved falsely suggests persistence already succeeded.

Design the payload for the parent’s decision

Outputs should carry enough information for the parent to make the next decision, but not necessarily an entire object graph.

export interface AllocationRemoveRequested {
  readonly allocationId: string;
  readonly reason?: 'user-action' | 'rule-violation';
}

Sending only an ID is often better than emitting the mutable allocation object because the parent can read the current canonical state before deleting. Conversely, a search component may appropriately emit a complete typed filter object because that object is the explicit user intent.

Avoid any. A typed output is both documentation and compile-time protection at every consuming template.


State ownership: one source of truth, with an intentional exception

The hardest follow-up question is usually not “What is input()?” It is:

“If the child receives an input and also changes it locally, who owns the source of truth?”

The safe default is unidirectional ownership:

  • parent owns business and workflow state;
  • child receives values through inputs;
  • child emits events describing user intent;
  • parent updates its state and passes the next value down.

This avoids the classic bug where a child holds a draft while the parent independently refreshes the same entity from the API. Both states can appear correct until an API response, navigation, or sibling interaction reveals that they diverged.

Local component state is appropriate when it is purely presentational and has no meaning outside the component:

readonly detailsExpanded = signal(false);

A parent generally should not own detailsExpanded unless another component, a route, accessibility restoration, or a business requirement needs to control it.

When model() is appropriate

A model() input is a deliberate exception. It is for a component whose essential job is to edit a value and synchronize the result with its parent: a quantity stepper, rating control, toggle, or custom date picker.

import { Component, model } from '@angular/core';

@Component({
  selector: 'bm-quantity-stepper',
  standalone: true,
  template: `
    <button type="button" (click)="decrement()">-</button>
    <span>{{ value() }}</span>
    <button type="button" (click)="increment()">+</button>
  `,
})
export class QuantityStepperComponent {
  readonly value = model.required<number>();

  protected increment(): void {
    this.value.update(current => current + 1);
  }

  protected decrement(): void {
    this.value.update(current => Math.max(0, current - 1));
  }
}

The parent can bind a signal instance bidirectionally:

readonly draftQuantity = signal(1);
<bm-quantity-stepper [(value)]="draftQuantity" />

When the child calls set() or update() on its model input, Angular updates the bound parent state. Angular also creates an implicit valueChange output.

Use model() when all of these are true:

  • the component directly modifies one value as its primary responsibility;
  • immediate synchronization is desirable;
  • the two-way relationship is obvious from the component’s meaning;
  • the parent remains the canonical owner of the value.

Do not use it merely to reduce boilerplate for a feature component with complicated save, cancel, validation, authorization, or server-error behavior. In an allocation editor, an explicit saveRequested event and parent-owned draft state are usually clearer. For form integration, model() also does not by itself replace the requirements of Angular’s forms APIs and custom form-control integration.

How Angular components should communicate in 2025

Watch “How Angular components should communicate in 2025” by Brian Treese for a short comparison between explicit input/output communication and a model input.

Watch explicit communication first. It shows the normal parent-to-child input and child-to-parent output pattern. Then watch model input to see when the same value-synchronization use case can use model() and two-way binding. The key decision is semantic clarity, not fewer lines of code.


Make the public API small and the template API non-public

Modern Angular style guidance reinforces a useful senior-level distinction:

  • Public members define the component’s external API.
  • Protected members exist to support its own template.
  • Private members are internal implementation details.

In the allocation card:

readonly allocation = input.required<FundAllocation>();
readonly editRequested = output<AllocationEditRequested>();

protected readonly remainingAmount = computed(/* ... */);
protected requestEdit(): void {
  // ...
}

allocation and editRequested are intentionally public because consumers bind to them. remainingAmount and requestEdit are only template implementation details, so protected documents that they are not part of the component contract.

Likewise, readonly on function-based inputs and outputs prevents accidental reassignment:

// This should not be possible after initialization.
readonly allocation = input.required<FundAllocation>();

It does not make the incoming data immutable, nor does it prevent updating a model() input through set() or update(). It protects the Angular-managed property itself.

Angular coding style guide

Read the relevant Angular style guidance for the practical consequences of a clear component contract: focused presentation responsibilities, visible Angular APIs, protected template members, and readonly Angular-managed properties.

In “Components and directives,” read component API organization. Then read presentation boundaries. Pay particular attention to the difference between extracting reusable non-UI logic and merely moving arbitrary code into a service. Next, read readonly guidance. Finally, in “Dependency injection,” review the inject recommendation. You will use inject() in the dependency-injection lesson; for now, recognize that injecting a service into a component should not become an excuse to hide feature orchestration inside every leaf component.


Interview follow-up chain: answer from the contract outward

For this question, avoid listing APIs before explaining the design. Use this sequence instead.

Interviewer: “Design a reusable allocation card component.”

Start with behavior.
“It displays one allocation and reports user requests to edit or remove it. It does not fetch, persist, or navigate.”

Explain mechanics.
“It receives a required input.required<FundAllocation>() and emits typed output() events carrying the allocation ID. Derived display values are computed() signals, so the child reads the current input reactively without copying it into mutable fields.”

State the trade-off.
“I prefer explicit outputs over two-way binding because edit and remove are workflow actions. The parent owns the canonical allocation state and decides whether to open a modal, check permissions again, call the API, or refresh data.”

Handle an edge case.
“If an allocation becomes locked after a refresh, the parent passes the new allocation snapshot; the card derives that editing is unavailable. The server must still enforce the rule because disabling a browser button is not authorization.”

Give the production example.
“In a channel fund feature, the page coordinates API data, permissions, confirmation dialogs, notifications, and route state. The card remains reusable in both a list and a detail view because it has no dependency on those workflows.”

That is a mental model, not a speech to memorize. If you can reconstruct all five layers for a new component, you can handle most probing questions.


Five-layer readiness check

Before moving on, verify that you can explain this lesson aloud without notes:

  • Behavior: I can define what a component renders and the user intents it reports.
  • Mechanics: I can distinguish input(), input.required(), output(), computed(), and model(), including why signal inputs are read as functions.
  • Trade-offs: I can justify explicit input/output communication versus a model() input for a particular UI control.
  • Edge cases: I can explain why a required input is not runtime validation, why input objects should not be mutated by the child, and why UI permissions are not server authorization.
  • Production example: I can divide a feature into parent-owned workflow state, child-owned presentation state, typed component events, and extracted reusable logic.

The next lesson examines when Angular makes these contracts available and usable: lifecycle-hook and render-callback order, view interaction timing, and DestroyRef-based cleanup.

Can't find a good explanation? Sign up and we'll make it for you

Sign up