How to Use Angular with TypeScript
Angular validates your templates at build time, catching type errors that would crash at runtime in other frameworks. When your API returns { user: { displayName: string } } but your template expects user.name, Angular's TypeScript integration flags the mismatch immediately. No manual type guards, no runtime checks, just compile-time safety built into the framework.
TypeScript isn't optional in Angular. Every Angular CLI project starts with TypeScript configured, decorators enabled, and strict template checking ready to go. The framework's dependency injection, reactive forms, and RxJS operators all provide full type inference. You get autocomplete for component inputs, type-checked templates, and compile errors when you pass the wrong props. Building type-safe Angular applications means fewer production bugs and faster development.
Here's what makes Angular TypeScript different: the strictTemplates flag in tsconfig.json turns your HTML templates into fully type-checked code. Reference a property that doesn't exist? Compile error. Pass the wrong type to a pipe? Compile error. Call a method with incorrect arguments in an event handler? Compile error. While Svelte with TypeScript compiles templates at build time too, Angular's approach works deeply with decorators and dependency injection for complete type safety.
Starting an Angular Project with TypeScript
The Angular CLI handles all the TypeScript configuration for you:
npm install -g @angular/cli
ng new my-angular-app
cd my-angular-app
ng serve
The CLI creates a complete TypeScript setup with a tsconfig.json configured specifically for Angular. Here's what a typical Angular tsconfig.json looks like:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"],
"strict": true, // Enables all strict type checking
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"experimentalDecorators": true, // Required for Angular decorators
"useDefineForClassFields": false // Required for Angular dependency injection
},
"angularCompilerOptions": {
"strictTemplates": true, // Catch template errors at compile time
"strictInjectionParameters": true
}
}
The experimentalDecorators option is required because Angular uses decorators like @Component, @Injectable, and @Input everywhere. The strictTemplates flag in angularCompilerOptions catches template errors before runtime.
Component and Service Typing
Angular components are TypeScript classes with decorators. Here's how to properly type a component's properties and methods:
import { Component, Input, Output, EventEmitter } from '@angular/core';
interface UserProfile {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}
@Component({
selector: 'app-user-card',
template: `
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<span class="role-badge">{{ user.role }}</span>
<button (click)="handleEdit()">Edit</button>
</div>
`,
styleUrls: ['./user-card.component.css']
})
export class UserCardComponent {
@Input() user!: UserProfile; // ! tells TypeScript this will be set by parent
@Output() userEdited = new EventEmitter<UserProfile>();
handleEdit(): void {
// TypeScript knows user.role can only be 'admin' | 'user' | 'guest'
if (this.user.role === 'admin') {
this.userEdited.emit(this.user);
}
}
}
The exclamation mark (!) after user is the non-null assertion operator. It tells TypeScript that this property will definitely be assigned a value (by the parent component), even though it's not initialized in the constructor.
Services follow a similar pattern but use dependency injection:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
interface ApiResponse<T> {
data: T;
status: string;
message?: string;
}
// Note: Make sure HttpClient is provided in your app.config.ts:
// export const appConfig: ApplicationConfig = {
// providers: [provideHttpClient()]
// };
@Injectable({
providedIn: 'root' // Makes this service available application-wide
})
export class UserService {
private http = inject(HttpClient);
private apiUrl = 'https://api.example.com';
// TypeScript infers the return type: Observable<ApiResponse<UserProfile[]>>
getUsers(): Observable<ApiResponse<UserProfile[]>> {
return this.http.get<ApiResponse<UserProfile[]>>(`${this.apiUrl}/users`);
}
// Explicit parameter and return types prevent mistakes
updateUser(id: number, updates: Partial<UserProfile>): Observable<ApiResponse<UserProfile>> {
return this.http.patch<ApiResponse<UserProfile>>(
`${this.apiUrl}/users/${id}`,
updates
);
}
}
Modern Dependency Injection: The inject() function (introduced in Angular 14) is the recommended way to inject dependencies. Unlike constructor injection, inject() works in field initializers and provides better type inference. It's more flexible and concise, making it the preferred pattern in modern Angular applications.
Notice how we use Partial to type the updates parameter. This utility type makes all properties optional, perfect for patch requests.
Template Type Safety with Strict Checking
This is where Angular's TypeScript integration shines. With strictTemplates: true, Angular validates your templates at compile time:
@Component({
selector: 'app-product-list',
template: `
<div *ngFor="let product of products">
<h3>{{ product.name }}</h3>
<!-- ❌ TypeScript error: Property 'price' does not exist on type 'Product' -->
<p>{{ product.price }}</p>
<!-- ✅ This works because description is optional (?) in the interface -->
<p>{{ product.description ?? 'No description available' }}</p>
</div>
`
})
export class ProductListComponent {
products: Product[] = [];
}
interface Product {
id: string;
name: string;
description?: string;
// Note: No 'price' property - template error caught at compile time!
}
You can also type template variables:
@Component({
template: `
<!-- TypeScript knows user is UserProfile | null -->
<div *ngIf="user as typedUser">
<h2>{{ typedUser.name }}</h2>
<!-- typedUser is narrowed to UserProfile (not null) inside this block -->
</div>
<!-- Type the async pipe result -->
<div *ngIf="userData$ | async as user">
<p>{{ user.email }}</p>
</div>
`
})
export class UserDetailComponent {
user: UserProfile | null = null;
userData$: Observable<UserProfile>;
}
Reactive Forms with TypeScript
Angular's reactive forms got much better with typed forms. Here's how to create type-safe forms:
import { Component } from '@angular/core';
import { FormGroup, FormControl, Validators, NonNullableFormBuilder } from '@angular/forms';
interface LoginFormValue {
email: string;
password: string;
rememberMe: boolean;
}
@Component({
selector: 'app-login',
template: `
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
<input type="email" formControlName="email" placeholder="Email" />
<input type="password" formControlName="password" placeholder="Password" />
<label>
<input type="checkbox" formControlName="rememberMe" />
Remember me
</label>
<button type="submit" [disabled]="loginForm.invalid">Log In</button>
</form>
`
})
export class LoginComponent {
// Typed FormGroup - TypeScript knows the shape
loginForm = new FormGroup({
email: new FormControl<string>('', {
nonNullable: true,
validators: [Validators.required, Validators.email]
}),
password: new FormControl<string>('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(8)]
}),
rememberMe: new FormControl<boolean>(false, { nonNullable: true })
});
onSubmit(): void {
if (this.loginForm.valid) {
// TypeScript infers the correct type
const formValue = this.loginForm.getRawValue();
// formValue is { email: string; password: string; rememberMe: boolean }
console.log('Email:', formValue.email); // ✅ Type-safe
console.log('Remember:', formValue.rememberMe); // ✅ Type-safe
}
}
}
The nonNullable: true option ensures that form values are never null, making your types cleaner. Without it, TypeScript infers FormControl<string | null>, which means you'd need null checks everywhere.
For more complex forms, you can define the form structure as an interface:
import { Injectable, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
interface UserRegistrationForm {
personalInfo: FormGroup<{
firstName: FormControl<string>;
lastName: FormControl<string>;
}>;
credentials: FormGroup<{
email: FormControl<string>;
password: FormControl<string>;
}>;
}
@Injectable({ providedIn: 'root' })
export class RegistrationFormService {
private fb = inject(FormBuilder).nonNullable;
createRegistrationForm(): FormGroup {
return this.fb.group({
personalInfo: this.fb.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required]
}),
credentials: this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]]
})
});
}
}
Note on Signal Forms: Angular 21+ introduces Signal Forms (experimental), which represent the future of forms in Angular with better type safety and simpler syntax. While Signal Forms are still experimental and the API may change before stable release, they're worth watching for future projects. For production applications today, stick with the Reactive Forms patterns shown above. Learn more at the official Angular Signal Forms guide.
Advanced Typing Patterns
Generic Components
Generics let you create reusable components that work with different data types:
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-data-table',
template: `
<table>
<thead>
<tr>
<th *ngFor="let column of columns">{{ column }}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of items" (click)="rowClicked.emit(item)">
<td *ngFor="let column of columns">
{{ item[column] }}
</td>
</tr>
</tbody>
</table>
`
})
export class DataTableComponent<T extends Record<string, unknown>> {
@Input() items!: T[];
@Input() columns!: (keyof T)[]; // Only allows valid keys of T
@Output() rowClicked = new EventEmitter<T>();
}
// Usage:
@Component({
template: `
<app-data-table
[items]="users"
[columns]="['name', 'email', 'role']"
(rowClicked)="handleUserClick($event)"
></app-data-table>
`
})
export class UserListComponent {
users: UserProfile[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' }
];
handleUserClick(user: UserProfile): void {
// TypeScript knows user is UserProfile
console.log('Clicked user:', user.name);
}
}
Utility Types with Angular Models
TypeScript's utility types work great with Angular:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
interface Product {
id: string;
name: string;
description: string;
price: number;
category: string;
inStock: boolean;
}
// Pick only the fields you need for the list view
type ProductListItem = Pick<Product, 'id' | 'name' | 'price' | 'inStock'>;
// Omit sensitive fields when sending to the client
type PublicProduct = Omit<Product, 'inStock'>;
// Make all fields optional for updates
type ProductUpdate = Partial<Product>;
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private apiUrl = 'https://api.example.com';
getProductList(): Observable<ProductListItem[]> {
// Only fetches the fields we need for the list
return this.http.get<ProductListItem[]>(`${this.apiUrl}/products?fields=id,name,price,inStock`);
}
updateProduct(id: string, updates: ProductUpdate): Observable<Product> {
// updates can have any subset of Product fields
return this.http.patch<Product>(`${this.apiUrl}/products/${id}`, updates);
}
}
Pick, Omit, and Partial help you create focused types without duplicating interfaces.
Type-Safe RxJS Observables
RxJS is central to Angular, and typing your Observables correctly prevents runtime errors:
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
interface ApiError {
code: string;
message: string;
}
type Result<T> = { success: true; data: T } | { success: false; error: ApiError };
@Injectable({ providedIn: 'root' })
export class DataService {
private http = inject(HttpClient);
// Explicitly typed Observable helps catch errors in the pipe
fetchUser(id: number): Observable<Result<UserProfile>> {
return this.http.get<UserProfile>(`/api/users/${id}`).pipe(
map(user => ({ success: true as const, data: user })),
catchError(err => of({
success: false as const,
error: { code: 'FETCH_ERROR', message: err.message }
}))
);
}
}
@Component({
template: `
<div *ngIf="userResult$ | async as result">
<!-- TypeScript knows to narrow the type based on success -->
<div *ngIf="result.success">
<h2>{{ result.data.name }}</h2>
</div>
<div *ngIf="!result.success" class="error">
{{ result.error.message }}
</div>
</div>
`
})
export class UserComponent {
userResult$: Observable<Result<UserProfile>>;
}
Using discriminated unions with the success flag lets TypeScript narrow the type automatically in your template.
Solving Angular TypeScript Challenges
ViewChild Returns Undefined
One of the most frustrating errors is when @ViewChild returns undefined:
import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
@Component({
template: `
<input #searchInput type="text" />
<div *ngIf="showResults">
<div #resultsContainer></div>
</div>
`
})
export class SearchComponent implements AfterViewInit {
@ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>;
// ❌ This will be undefined if showResults is false
@ViewChild('resultsContainer') resultsContainer?: ElementRef<HTMLDivElement>;
ngAfterViewInit(): void {
// ✅ searchInput is guaranteed to exist
this.searchInput.nativeElement.focus();
// ❌ resultsContainer might be undefined because of *ngIf
// Always check before accessing
if (this.resultsContainer) {
console.log(this.resultsContainer.nativeElement);
}
}
}
The ! operator tells TypeScript "this will be assigned," while ? means "this might be undefined." Use ! for elements that are always present and ? for elements inside *ngIf or *ngFor.
Property Does Not Exist in Templates
When you get "Property 'x' does not exist on type..." in templates:
@Component({
template: `
<div *ngIf="user">
<!-- ❌ Error: Property 'name' does not exist on type 'never' -->
<h2>{{ user.name }}</h2>
</div>
`
})
export class UserComponent {
// Problem: TypeScript can't infer the type after *ngIf
user: UserProfile | null = null;
}
// ✅ Solution 1: Use template type assertion
@Component({
template: `
<div *ngIf="user as typedUser">
<h2>{{ typedUser.name }}</h2>
</div>
`
})
export class UserComponentFixed {
user: UserProfile | null = null;
}
// ✅ Solution 2: Create a type guard
@Component({
template: `
<div *ngIf="hasUser()">
<h2>{{ user.name }}</h2>
</div>
`
})
export class UserComponentWithGuard {
user: UserProfile | null = null;
hasUser(): this is { user: UserProfile } {
return this.user !== null;
}
}
Strict Null Checking Issues
Angular's strict mode catches potential null/undefined errors, but you need to handle them:
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
template: `<h1>Product {{ productId }}</h1>`
})
export class ProductDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
productId!: string;
ngOnInit(): void {
// ❌ TypeScript error: Type 'string | null' is not assignable to type 'string'
// this.productId = this.route.snapshot.paramMap.get('id');
// ✅ Solution 1: Use nullish coalescing with default
this.productId = this.route.snapshot.paramMap.get('id') ?? 'unknown';
// ✅ Solution 2: Throw if null (when the param is required)
const id = this.route.snapshot.paramMap.get('id');
if (!id) {
throw new Error('Product ID is required');
}
this.productId = id;
}
}
RxJS Type Inference Failures
RxJS operators sometimes lose type information:
import { Observable } from 'rxjs';
import { map, filter } from 'rxjs/operators';
interface ApiResponse {
data: UserProfile | null;
error?: string;
}
@Injectable({ providedIn: 'root' })
export class UserDataService {
getUsers(): Observable<UserProfile> {
return this.http.get<ApiResponse>('/api/users').pipe(
// ❌ filter doesn't narrow the type automatically
filter(response => response.data !== null),
// TypeScript still thinks response.data could be null
map(response => response.data) // Type error!
);
}
}
// ✅ Solution: Use a type predicate to narrow ApiResponse
function isSuccessfulResponse(
response: ApiResponse
): response is ApiResponse & { data: UserProfile } {
return response.data !== null;
}
@Injectable({ providedIn: 'root' })
export class UserDataServiceFixed {
private http = inject(HttpClient);
getUsers(): Observable<UserProfile> {
return this.http.get<ApiResponse>('/api/users').pipe(
filter(isSuccessfulResponse), // Now TypeScript knows data is not null
map(response => response.data) // ✅ Works!
);
}
}
Missing Type Definitions
Sometimes third-party Angular libraries don't include TypeScript types:
# ❌ Error: Could not find a declaration file for module 'some-angular-library'
npm install some-angular-library
# ✅ Solution: Install the types separately
npm install --save-dev @types/some-angular-library
# If @types don't exist, create a declaration file
# Create: src/typings.d.ts
declare module 'some-angular-library' {
export class SomeComponent {
// Add minimal type definitions
}
}
Building Production-Ready Angular Applications
Angular's type system scales from prototypes to production. Centralized state management, type-safe HTTP interceptors, and proper error handling keep large codebases maintainable as teams grow and features multiply.
Centralized State Management
For complex applications, consider a type-safe approach to state management. For async server state, TanStack Query for Angular offers type-safe data fetching across frameworks.
import { Injectable, signal, computed } from '@angular/core';
interface AppState {
user: UserProfile | null;
isLoading: boolean;
error: string | null;
}
@Injectable({ providedIn: 'root' })
export class StateService {
// Angular signals with strict typing
private state = signal<AppState>({
user: null,
isLoading: false,
error: null
});
// Computed values are type-safe
user = computed(() => this.state().user);
isAuthenticated = computed(() => this.state().user !== null);
isLoading = computed(() => this.state().isLoading);
setUser(user: UserProfile | null): void {
this.state.update(current => ({ ...current, user }));
}
setLoading(isLoading: boolean): void {
this.state.update(current => ({ ...current, isLoading }));
}
setError(error: string | null): void {
this.state.update(current => ({ ...current, error }));
}
}
For backend integration with full type safety, Convex provides a type-safe database that automatically generates TypeScript types for your schema, ensuring your Angular components always match your backend data structure.
Type-Safe HTTP Interceptors
Interceptors should handle errors and transformations in a type-safe way:
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { Router } from '@angular/router';
interface ApiError {
code: string;
message: string;
statusCode: number;
}
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
const apiError: ApiError = {
code: error.error?.code ?? 'UNKNOWN_ERROR',
message: error.error?.message ?? 'An unexpected error occurred',
statusCode: error.status
};
if (apiError.statusCode === 401) {
router.navigate(['/login']);
}
return throwError(() => apiError);
})
);
};
Making Angular TypeScript Work for You
Angular's type system catches whole classes of bugs before deployment. The strictTemplates compiler option validates every template expression against your component's types. Enable it and watch property access errors, incorrect pipe arguments, and wrong event handler signatures get flagged in your editor, not after pushing to production.
Forms get safer with typed FormControl<T> instances. The nonNullable option removes null from your form value types, eliminating a whole category of null checks from your submission handlers. When you call form.getRawValue(), TypeScript knows exactly what shape you'll get back.
RxJS streams need explicit typing. Observable chains lose type information through operators like filter, so use type predicates to help TypeScript understand what your data becomes after transformation. Discriminated unions with status flags (success, error, loading) prevent accessing data properties before they exist.
Template refs need attention. Elements that always render get the ! assertion. Conditional elements behind *ngIf need the ? optional marker. TypeScript can't see your template structure, so these markers tell it what to expect at runtime.
Build reusable components with generics. A data table component typed as Component<T extends { id: string }> works with any data shape while keeping full type safety for column definitions and row clicks. Utility types like Partial, Pick, and Omit create focused interfaces from existing types without repeating yourself.
For backend integration with automatic type generation, Convex creates TypeScript definitions from your database schema. Your Angular services get end-to-end type safety from queries to components.