TypeScript Essentials for Angular: Decorators, Generics, Strict Mode, and Template Narrowing [2026]
Angular is the only major framework where TypeScript is mandatory, not optional. The compiler does not just type-check your code — it parses your templates, infers signal generic parameters, narrows types inside @if blocks, and refuses to compile a build with a misspelled input name. This is the most-leveraged TypeScript surface in front-end web development.
This is lesson 1.4 of the Angular Tutorial. Module 1's earlier lessons covered the CLI, dependency injection, and bootstrap. This one is a focused tour of the TypeScript slice an Angular developer actually touches every day — decorators, generics, types vs interfaces, strict mode, and template-level type narrowing. It assumes you have written some TypeScript before (functions, basic interfaces, unknown vs any). If those words are unfamiliar, the TypeScript Handbook's first three chapters are the right primer.
What you do not need to know #
A reassuring observation up front: most of the heaviest TypeScript features — conditional types, mapped types, template-literal types, infer — almost never appear in application code. Library authors use them; you mostly consume them through good type definitions. The TypeScript you write in Angular is the boring kind: interfaces, function signatures, union types, and generics with one or two type parameters. The bar is lower than the TypeScript Twitter discourse suggests.
Decorators: what Angular uses them for #
Decorators are functions that attach metadata to a class, method, or property. Angular relies on five core decorators; you will see them constantly:
| Decorator | Where it goes | What it does |
|---|---|---|
@Component({...}) |
On a class | Marks the class as a component, supplies template, styles, selector, providers |
@Directive({...}) |
On a class | Same as @Component but without a template |
@Injectable({...}) |
On a class | Marks the class as a service; lets it receive DI |
@Pipe({...}) |
On a class | Marks the class as a template pipe |
@Input() / @Output() (legacy) |
On a property | Pre-v17 way of declaring inputs/outputs (replaced by input() / output() signal functions) |
The modern code you will write uses signal-based input() and output() functions in fields, not decorators on properties. But every component class still has @Component({...}) on it — that has not changed.
Decorators run at class-declaration time. Whatever object you pass to @Component({ selector: 'foo', template: '...' }) is stored on the class as metadata; Angular's compiler reads that metadata to wire the component up. There is no runtime decorator magic to learn — it is just "give me a static object that describes this class."
If you want to write your own decorators, you almost certainly do not need to. Angular's set is sufficient for application code.
Generics: the ones you actually use #
Angular's APIs are full of generic parameters, but day-to-day you write or read about five shapes:
import { signal, computed, input, output, WritableSignal } from '@angular/core';
// 1. signal<T>() — TypeScript infers T from the initial value
const count = signal(0); // WritableSignal<number>
const user = signal<User | null>(null); // explicit T when initial is null/undefined
// 2. computed<T>() — T inferred from the function's return type
const doubled = computed(() => count() * 2); // Signal<number>
// 3. input<T>() — T inferred from default, or declared
class MyComp {
size = input(16); // InputSignal<number>
required = input.required<string>(); // InputSignal<string>
user = input<User | null>(null); // optional, typed
}
// 4. output<T>() — T is the event payload type
class MyComp {
saved = output<{ id: number }>();
}
// 5. HttpClient.get<T>() — T is the response shape
this.http.get<User[]>('/api/users').subscribe(users => { ... });
The pattern is identical across all of them: a function or method takes a generic T, you either let TypeScript infer it from arguments or supply it in <>. You almost never define your own generic types in application code — but reading the API signatures is a daily activity.
The one consumer-side generic worth knowing: WritableSignal<T> is what you typically annotate function parameters with when a method takes a signal and might write to it:
function increment(s: WritableSignal<number>) {
s.update(n => n + 1);
}
Signal<T> is the read-only flavor; WritableSignal<T> extends it and adds .set()/.update().
Types vs interfaces: pick one, mostly #
TypeScript has two ways to name an object shape:
interface User {
id: number;
name: string;
}
type User = {
id: number;
name: string;
};
For an application Angular codebase, the difference does not matter for 90% of cases. Pick one convention and stick with it. The Angular team itself uses interface for object shapes and type for unions or computed types. That is the convention this tutorial follows:
interface User { id: number; name: string; } // object shape → interface
type UserId = User['id']; // pluck a field → type
type LoadState = 'idle' | 'loading' | 'success' | 'error'; // union → type
A real difference: interface is open (can be re-declared and merged); type is closed (a second type Foo = ... is an error). Library authors care about that. Application code rarely does.
Strict mode: which flags matter #
The Angular CLI defaults to a strict tsconfig in 2026 — strict: true and a handful of Angular-specific flags. You should not relax them. The five flags that catch the most bugs:
| Flag | What it catches |
|---|---|
strictNullChecks |
Forgetting to handle null / undefined in function arguments, object property accesses |
noImplicitAny |
A variable or parameter whose type TypeScript cannot infer — usually a missed import |
strictPropertyInitialization |
Class fields declared with a type but never initialized (a common Angular component bug pre-v15) |
strictFunctionTypes |
Passing a function with wrong parameter variance — caught most often in callback signatures |
strictTemplates (Angular-specific) |
Template expressions that don't match the bound input's type |
The Angular-specific strictTemplates flag is the biggest win. With it on, {{ user.nmae }} (typo) fails the build at compile time, not at runtime. Same for misspelled input/output names, wrong event types, and binding a string to a number input. Leave it on.
Type narrowing in templates with @if #
The single sharpest TypeScript feature for Angular work is template-level type narrowing. Inside an @if block, the type system narrows the bound expression — exactly like a TypeScript if statement narrows in code:
@Component({
template: `
@if (user(); as u) {
<!-- u is typed as User, not User | null -->
<p>{{ u.name }}</p>
<p>{{ u.email }}</p>
} @else {
<button (click)="signIn()">Sign in</button>
}
`,
})
export class HeaderComponent {
user = signal<User | null>(null);
}
The as u clause names the narrowed value. Inside the block, u is User — TypeScript and the template compiler both know that the null case is impossible there. Type errors on u.name, u.email, or any other access happen at compile time.
The same pattern works for discriminated unions:
type Loadable<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; message: string };
@Component({
template: `
@switch (state().status) {
@case ('idle') { <button (click)="load()">Load</button> }
@case ('loading') { <p>Loading...</p> }
@case ('success') { <p>{{ state().data.length }} items</p> }
@case ('error') { <p>Error: {{ state().message }}</p> }
}
`,
})
export class ListComponent {
state = signal<Loadable<Item[]>>({ status: 'idle' });
}
In the success branch, state().data is typed Item[]. In the error branch, state().message is string. Outside the branches, both fields would be errors.
Discriminated unions plus @switch is the cleanest way to model loadable state in 2026 Angular. Old codebases use a flat shape with optional fields (data?: T[]; loading: boolean; error?: string) — that works but loses type narrowing. Migrate when you can.
Path aliases for cleaner imports #
After a project grows past a few folders, relative imports get ugly:
import { AuthService } from '../../../core/auth/auth.service';
TSConfig path aliases fix it:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@core/*": ["src/app/core/*"],
"@features/*": ["src/app/features/*"],
"@shared/*": ["src/app/shared/*"]
}
}
}
// Cleaner
import { AuthService } from '@core/auth/auth.service';
The Angular CLI respects these out of the box (esbuild reads them too). Set them up when the relative imports start looking ridiculous, not before.
TypeScript gotchas specific to Angular #
| Symptom | Cause | Fix |
|---|---|---|
Property 'x' has no initializer and is not definitely assigned |
A class field has a type but no initial value (likely an @Input() decorator pre-v17) |
Provide an initial value, mark with ! (x!: string), or switch to input() signal which handles this |
Object is possibly 'null' on a signal read |
signal<T | null>(...) returns a possibly-null value |
Either narrow with @if (s(); as v) in the template, or check if (s()) in TS |
Type 'Event' is not assignable to type 'InputEvent' |
The DOM event's static type is Event; the specific subtype isn't inferred |
Cast in the handler: ($event as InputEvent).data or use Angular's (input) event types |
Cannot find name 'X' in template only, fine in code |
The template compiler can't see something your TS code can — usually because the symbol isn't in the component's imports array |
Add it to the component's imports: [...] |
Cannot find module '@core/...' or its corresponding type declarations |
TSConfig path aliases set but esbuild/IDE hasn't reloaded | Restart the dev server (ng serve); IDE usually picks it up on file save |
What's next #
Lesson 1.5 — the last of Module 1 — walks the modern Angular build pipeline: what ng serve and ng build actually do under the hood, esbuild vs Vite, AOT compilation, source maps, and the budget warnings you can safely tune. After that, Module 2 starts — components, templates, pipes, directives — and TypeScript becomes the silent partner that makes everything in it possible.
Try it yourself #
The sharpest demo of strictTemplates is to break it on purpose:
@Component({
template: `<p>{{ user.naem }}</p>`, // ← typo: nmae instead of name
})
export class BrokenComponent {
user = signal<User>({ id: 1, name: 'Pradeep' });
}
Run ng build. You get a real compile error pointing at the template, not a runtime undefined at user-render time. Fix the typo, rebuild, ship — that loop is the heart of the Angular + TypeScript productivity story.
get_best_practicesAll of them. The Angular CLI’s default tsconfig has strict: true (turns on all strict-family flags) plus three Angular-specific ones — strictTemplates, strictInputAccessModifiers, strictInjectionParameters. Leave the lot on for new code. Disabling one always feels like a small win and pays back as a real bug six months later.Lesson 1.5 closes Module 1 with the build pipeline.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


