Building Composable Form Fields in Angular

don't judge the design by how many lines of Angular code it takes compared with React\. Judge it by whether the resulting API is clear, whether the integration is reliable, and whether future consumers can compose it without understanding the internal plumbing\.

Recently, I worked on integrating our design system with Angular Signal Forms.

The initial goal seemed straightforward: create a form field component that accepts a label, a description and an input, then handles validation states and error messages automatically.

We already had the fundamental components. We had input directives for form controls, an input box for visual elements such as borders, prefixes and suffixes, and a component that wrapped a label, description and input box.

It seemed like a relatively simple integration task.

It turned out to be a much more interesting problem about component boundaries, composition and communication.

Separating responsibilities

The first thing I realised was that our existing form field component wasn’t really a form field.

It was a UI wrapper. It provided a label, description and input box, but it had no connection to the actual form state.

The input directive handled the control itself. The input box handled its visual presentation, including borders, prefixes and suffixes. The wrapper simply brought these elements together.

These responsibilities are different, and I think they should remain separate.

The input should own its control behaviour. The input box should own its visual shell. The form field should handle field-level concerns such as labels, descriptions, validation messages and accessibility relationships.

This distinction also explains why the input box doesn’t need to replace the input.

In React, we sometimes wrap an input and forward its props through the wrapper. Angular doesn’t provide an equivalent general-purpose mechanism for transparently forwarding arbitrary properties to a child component or element.

But that isn’t necessarily a problem. The input box is not the input. It is a container around the input, and the two components have different responsibilities.

Once I stopped treating this as a limitation that needed to be worked around, the component boundaries became clearer.

Why composition became complicated

The next decision was how to structure the form field itself.

One option was to keep the label, description and error message inside a single component. Another was to make them independently composable:

1
2
3
4
5
6
7
8
9
<app-form-field>
<app-form-field-label>
Amount
</app-form-field-label>

<input appInput />

<app-form-field-error />
</app-form-field>

I prefer the composable approach for good reasons.

Different consumers may need different layouts. A label might eventually need different presentation. Some fields might not need descriptions, while others might need additional content. We can also provide preset components for common cases without taking flexibility away from consumers who need something different.

The preset becomes a convenient API built on top of reusable components, rather than a separate implementation with duplicated behaviour.

However, this flexibility introduces another problem: how do these components communicate?

The label and error components need access to the form field’s state. The form field needs to understand the associated control’s validation state. The input box may also need to know whether the field is invalid so it can change its border styling.

These components are separate, but they participate in the same interaction.

In Angular, I ended up introducing an injection token as a shared context. The form field obtains the relevant state, exposes the context, and the label and error components inject it when they need that information.

The implementation is reasonable, but it is considerably more involved than I initially expected.

Why does this feel easier in React?

React makes this style of composition feel natural.

A compound component can use context to share state between its parent and children. Hooks can encapsulate the logic for accessing that context. Components can be composed, omitted or rearranged without requiring every piece of state to be passed through intermediate components.

Angular has dependency injection, content projection and queries, so the necessary building blocks exist. But we need to connect these mechanisms explicitly.

The difference is not that React eliminates the underlying complexity. React’s context and component model make this particular communication pattern easier to express.

In Angular, I have to think more deliberately about how the parent discovers the relevant form state, how child components access it, and how the integration works with the form API.

This is especially noticeable when the component is intended to work with form state rather than simply render a visual layout.

Composition has a cost

This experience reminded me that composition is not free.

We often prefer composition over configuration because it produces smaller components with clearer responsibilities. Instead of creating one component with many inputs, variants and conditional layouts, we let consumers assemble the pieces they need.

That is still a useful principle.

But there is a cost that is easy to overlook: the more independently composable components become, the more coordination they may require.

A configurable component has complexity in its public API. A composable component can have complexity in its internal communication.

Neither is inherently better. The right choice depends on the problem being solved.

For example, independently composable label and error components are valuable if consumers genuinely need to control their placement or presentation. If every consumer uses exactly the same layout, a single form field component may be simpler.

The important question is not whether a component can be made composable. It is whether the flexibility justifies the additional complexity.

A better boundary, not necessarily fewer components

I don’t think the answer is to abandon composition or combine everything into a single component.

Instead, I want to keep the responsibilities explicit:

  • Input: owns control behaviour and integration with the form API.

  • Input box: owns the visual shell, including borders, prefixes and suffixes.

  • Form field: owns field-level presentation, validation messages and accessibility.

  • Preset components: assemble these pieces for common use cases.

The composable API remains available for consumers who need more control. Presets make the common cases straightforward.

The implementation details can be more involved than the public API suggests, but that is acceptable as long as the internal coordination remains well-defined and the complexity does not leak into every consumer.

There is also a limit. If each new component requires another context token, another state bridge and another layer of coordination, it is worth revisiting the boundaries.

The takeaway

Composition is a powerful way to design reusable components, but it does not automatically make a system simpler.

The goal is not to maximise the number of small components. It is to create components with clear responsibilities and give them just enough communication to work together.

Sometimes the hard part of component design isn’t deciding what each component should do. It is deciding how independently designed components should understand each other.

That is the problem I thought would be easy when I started integrating our design system with Angular forms.

I think the naming distinction is important here: your existing component is a visual layout wrapper, while the new one has actual form-field semantics and integrates with form state. Calling both FormField would hide that difference.

For the existing component, FormItem is a reasonable candidate, although it can also imply a semantic form field in some design systems. FieldLayout would communicate its presentation-only responsibility more explicitly. I’d use FieldLayout for the UI-only wrapper and reserve FormField for the component that handles form state, validation messages and accessibility.

Here’s a draft for your technical blog, focusing on the architectural lesson rather than just the Angular implementation details.

Composition Is Easy Until Components Need to Talk

Recently, I worked on integrating our design system with Angular Signal Forms.

The initial goal seemed straightforward: create a form field component that accepts a label, a description and an input, then handles validation states and error messages automatically.

We already had the fundamental components. We had input directives for form controls, an input box for visual elements such as borders, prefixes and suffixes, and a component that wrapped a label, description and input box.

It seemed like a relatively simple integration task.

It turned out to be a much more interesting problem about component boundaries, composition and communication.

Separating responsibilities

The first thing I realised was that our existing form field component wasn’t really a form field.

It was a UI wrapper. It provided a label, description and input box, but it had no connection to the actual form state.

The input directive handled the control itself. The input box handled its visual presentation, including borders, prefixes and suffixes. The wrapper simply brought these elements together.

These responsibilities are different, and I think they should remain separate.

The input should own its control behaviour. The input box should own its visual shell. The form field should handle field-level concerns such as labels, descriptions, validation messages and accessibility relationships.

This distinction also explains why the input box doesn’t need to replace the input.

In React, we sometimes wrap an input and forward its props through the wrapper. Angular doesn’t provide an equivalent general-purpose mechanism for transparently forwarding arbitrary properties to a child component or element.

But that isn’t necessarily a problem. The input box is not the input. It is a container around the input, and the two components have different responsibilities.

Once I stopped treating this as a limitation that needed to be worked around, the component boundaries became clearer.

Why composition became complicated

The next decision was how to structure the form field itself.

One option was to keep the label, description and error message inside a single component. Another was to make them independently composable:

1
2
3
4
5
6
7
8
9
<app-form-field>
<app-form-field-label>
Amount
</app-form-field-label>

<input appInput />

<app-form-field-error />
</app-form-field>

I prefer the composable approach for good reasons.

Different consumers may need different layouts. A label might eventually need different presentation. Some fields might not need descriptions, while others might need additional content. We can also provide preset components for common cases without taking flexibility away from consumers who need something different.

The preset becomes a convenient API built on top of reusable components, rather than a separate implementation with duplicated behaviour.

However, this flexibility introduces another problem: how do these components communicate?

The label and error components need access to the form field’s state. The form field needs to understand the associated control’s validation state. The input box may also need to know whether the field is invalid so it can change its border styling.

These components are separate, but they participate in the same interaction.

In Angular, I ended up introducing an injection token as a shared context. The form field obtains the relevant state, exposes the context, and the label and error components inject it when they need that information.

The implementation is reasonable, but it is considerably more involved than I initially expected.

Why does this feel easier in React?

React makes this style of composition feel natural.

A compound component can use context to share state between its parent and children. Hooks can encapsulate the logic for accessing that context. Components can be composed, omitted or rearranged without requiring every piece of state to be passed through intermediate components.

Angular has dependency injection, content projection and queries, so the necessary building blocks exist. But we need to connect these mechanisms explicitly.

The difference is not that React eliminates the underlying complexity. React’s context and component model make this particular communication pattern easier to express.

In Angular, I have to think more deliberately about how the parent discovers the relevant form state, how child components access it, and how the integration works with the form API.

This is especially noticeable when the component is intended to work with form state rather than simply render a visual layout.

Composition has a cost

This experience reminded me that composition is not free.

We often prefer composition over configuration because it produces smaller components with clearer responsibilities. Instead of creating one component with many inputs, variants and conditional layouts, we let consumers assemble the pieces they need.

That is still a useful principle.

But there is a cost that is easy to overlook: the more independently composable components become, the more coordination they may require.

A configurable component has complexity in its public API. A composable component can have complexity in its internal communication.

Neither is inherently better. The right choice depends on the problem being solved.

For example, independently composable label and error components are valuable if consumers genuinely need to control their placement or presentation. If every consumer uses exactly the same layout, a single form field component may be simpler.

The important question is not whether a component can be made composable. It is whether the flexibility justifies the additional complexity.

A better boundary, not necessarily fewer components

I don’t think the answer is to abandon composition or combine everything into a single component.

Instead, I want to keep the responsibilities explicit:

  • Input: owns control behaviour and integration with the form API.

  • Input box: owns the visual shell, including borders, prefixes and suffixes.

  • Form field: owns field-level presentation, validation messages and accessibility.

  • Preset components: assemble these pieces for common use cases.

The composable API remains available for consumers who need more control. Presets make the common cases straightforward.

The implementation details can be more involved than the public API suggests, but that is acceptable as long as the internal coordination remains well-defined and the complexity does not leak into every consumer.

There is also a limit. If each new component requires another context token, another state bridge and another layer of coordination, it is worth revisiting the boundaries.

The takeaway

Composition is a powerful way to design reusable components, but it does not automatically make a system simpler.

The goal is not to maximise the number of small components. It is to create components with clear responsibilities and give them just enough communication to work together.

Sometimes the hard part of component design isn’t deciding what each component should do. It is deciding how independently designed components should understand each other.

That is the problem I thought would be easy when I started integrating our design system with Angular forms.

Show the actual Angular boundaryMake the design-system lesson explicit

Typed Errors: Is Effect Solving Too Much?

Recently, one of my colleagues introduced Effect as a way to solve a problem we have in TypeScript: typed errors.

The problem is quite simple.

In normal TypeScript, I might write:

1
2
3
4
5
6
7
8
9
async function getUser(id: string): Promise<User> {
const user = await repository.findById(id);

if (!user) {
throw new UserNotFoundError(id);
}

return user;
}

The function signature tells me that it returns a User, but tells me nothing about how it can fail.

I might want the contract to be more explicit:

1
getUser(id): User throws UserNotFound

That sounds useful. And I think it is.

But then I looked at how we would express the same thing with Effect.

Instead of ordinary async/await:

1
2
3
const user = await getUser(id);
const account = await getAccount(user.accountId);
return account;

we might write something like:

1
2
3
4
5
6
return Effect.gen(function* () {
const user = yield* getUser(id);
const account = yield* getAccount(user.accountId);

return account;
});

And now the surrounding functions also need to speak Effect.

The error type becomes part of:

1
Effect<Value, Error, Dependencies>

and Effect introduces its own vocabulary:

1
2
3
4
5
6
7
8
Effect.gen
yield*
Effect.map
Effect.flatMap
Effect.catchTag
Effect.tryPromise
Effect.fail
...

None of these APIs are bad. In fact, they are powerful and well designed.

My concern is the cost of the programming model.

I started with a relatively small problem—“I want to know which business errors a function can produce”—but the solution seems to require changing how we write and read a large part of the application.

At some point, it no longer feels like adding a library to TypeScript. It feels like introducing another language inside TypeScript.

Is Typed Error Actually Worth Solving?

I think it is, but only for some errors.

Consider these:

1
2
3
UserNotFound
InsufficientBalance
AccountAlreadyClosed

These are business errors. The caller may actually need to make a decision based on them.

But what about:

1
2
3
4
DatabaseConnectionError
NetworkTimeout
UnexpectedState
ProgrammingError

Do I really want these errors propagated through every layer of the application and included in every function’s type?

Usually, I don’t.

Unexpected technical failures can often just propagate to an application-level error boundary, where they are logged and turned into an appropriate response.

So the problem I want to solve is narrower:

How can we make important business errors explicit without making every possible failure part of the type system?

Looking at Other Languages

Rust takes an interesting approach with:

1
Result<T, E>

and the ? operator:

1
let user = get_user(id)?;

The error is explicit in the type, but propagating it is almost free syntactically.

Swift takes another approach:

1
func getUser(id: ID) throws -> User

and now also supports typed throws:

1
func getUser(id: ID) throws(UserNotFoundError) -> User

I find both approaches interesting because error handling is supported directly by the language rather than requiring an entire effect abstraction.

What I Would Prefer

Ideally, TypeScript could have something conceptually like:

1
function getUser(id: string): User throws UserNotFound

and a lightweight propagation mechanism similar to Rust’s ?.

The compiler could understand the error contract and propagate it through the call chain.

At the same time, unexpected errors could remain ordinary JavaScript exceptions.

That would give us something like:

1
2
3
4
5
6
7
Business error
↓
typed contract
↓
compiler-assisted propagation
↓
handle at the appropriate boundary

without requiring the entire application to adopt a new programming model.

So my current view is not that typed errors are a bad idea.

Quite the opposite.

I think they solve a real problem.

My question is whether Effect is solving too much to solve it.

What I really want is not an effect system just to get typed errors.

I want typed errors to feel like a natural part of the language.

Error Handling & Logging Best Practice

Some context, when reviewing our code I feel some catch and throw actually useless\. Some logging are useless as well\. So here trying to figure out best practice\.

Catch - Throw Rules

Only use catch - throw (here only talking about re throw) when

  1. Change the business semantics. e.g. DB unique violation → ConflictError with 409

  2. HTTP status change. e.g. Turning a generic error into a specific status.

  3. Add additional info into error log.

Some anti-pattern:

1
2
3
4
5
6
7
8
9
10
11
12
// add no value, just noise. as correlationId can be found in logger already.
catch (error) {
this.logger.error('Something failed', { correlationId, error });
throw error;
}

// Some is throwing a new error type but actually no one cares about it.
// Also lose the error.cause.
catch (error) {
throw new XXXInternalError('UNIQUE_CODE');
}
// Question?: is a unique, greppable string worth the cost of a custom error class, a catch block, and lost error chain?

Logging Best Practice

1. Log at the boundary, not at every layer.

ClientService, ThirdParty. Should have logger already (with interceptor).
DB request and response logging is debug level off in prod.

2. Don’t log what’s already in the trace context

🙋‍♂️ But we can’t see it in Grafana logs.
But indeed, something has been logged automatically as rebind logger so it’s added in logger context: traceId, correlationId

3. Use the right log level

Seems very basic, but just realized I was actually using the wrong log level. I was abusing log\.info

info should be used only on Boundary. Or business outcome like card/account created

4. Log what the error does’t already tell you

N/A

5. Don’t log sensitive data

We’re using LOGGING\_MASKED\_SENSITIVE\_FIELDS to mask the PII

6. Prefer structured metadata over string interpolation

Bad:

1
this.logger.info(`Publish data for page: ${page}`);

Good:

1
this.logger.info('Publish data', { page });

7. Log the outcome, not the attempt

Verbose:

1
2
3
4
this.logger.info('Creating account in DA');
this.logger.info('Creating account in DB');
// do work
this.logger.info('Account created', { accountNumber });

Better:

1
2
3
// Optional this.logger.debug('Creating account in DA');
// Optional this.logger.debug('Creating account in DB');
this.logger.info('Account created', { accountNumber });

8. Catch - swallow must always log

N/A


I very often feel don’t have enough information for debugging. So tend to add noise into logger with info level.
Actually, if we have log on boundary and business outcome. It should be good. Other should be for debugging.

pino supports runtime level changes. We need to add endpoint, or listen to config, or request header for debug.