Workshop · Angular · Intermediate
NG100 Angular I
Angular 22 as working teams use it. Eight sessions and forty-eight steps cover the project anatomy and tooling, components with signals and content projection, template control flow and pipes, services and dependency injection, routing with lazy routes and guards, typed reactive forms with custom validators, and testing with Vitest, TestBed and coverage. Each step is one component, service or test you write in your own seat with the AI tutor beside you, and the course ends in Stockroom, a routed, tested inventory application with JSON import and export.
Open Coding Tutor at codingtutor.agilesystemsoftware.com, pick NG100 from the course button in the chat and press Tutor me. Every lesson, demo and hands-on step is also published below.
Who it is for
Developers who know TypeScript basics (types, functions, classes, modules) and have built at least one small web page, from NG050, a TypeScript course or work. You want to build real Angular 22 applications the way the framework is meant to be used today: standalone components, signals, typed forms, the router and tests.
Bridge. Coming from NG050: the concepts you already proved there are credited here (8 of them), and the first session re-teaches the syntax.
Where it leads. NG300 Angular, advanced
What you will be able to do
- Read and tune an Angular project: angular.json, strict tsconfig, ESLint and the production build
- Design components with signals, computed, effects, input()/output() and content projection
- Use @if/@for/@switch, track expressions and pipes, and write a custom pipe
- Share state and logic through injectable services with inject() and signal-based stores
- Route an application with provideRouter: parameters, lazy routes, guards and navigation
- Build typed reactive forms with FormBuilder, built-in and custom validators and clear error messages
- Test components and services with Vitest, TestBed and DOM queries, and read a coverage report
- Model data with discriminated unions, readonly arrays and immutable updates
Syllabus
Every session ends in a lab the machine checks on your own code. Open a session's mini steps to read each lesson, demo and hands-on task; the badge shows how far the engine can verify it: checked · T3 a check runs over your files, proven · T4 the grader also tries inputs you did not see, tutored · T0 done on your word. Each step has its own link (for example #s1-1).
- 1. Project anatomy and tooling
- 2. Components in depth
- 3. Template control flow and pipes
- 4. Services and dependency injection
- 5. Routing
- 6. Forms
- 7. Testing
- 8. Capstone: Stockroom
Session 1 · Project anatomy and tooling
Six steps that make an Angular project legible. What ng new generates and what each file is for; angular.json as the build's contract; tsconfig with strict and strictTemplates; the production build and its output; ESLint with typescript-eslint as the first reviewer; Prettier for formatting; Vitest as the test runner behind ng test. Live in the seat: a project read file by file, a template type error surfaced by strictTemplates, a lint rule catching an unused variable, and a budget warning on the production build.
Lab Set up the course project: strict TypeScript and templates, an ESLint config that passes, Prettier formatting, a production build under budget and one passing smoke test. Session 1 checkpoint.
6 mini steps · ~48 min
1.1What ng new made8 minartefact · T2
Predict
Which file tells the Angular CLI where the entry point, assets and budgets are?
Lesson
An Angular workspace is a small set of contracts: package.json (dependencies and scripts), angular.json (build and test targets), tsconfig*.json (compiler), src/main.ts (bootstrap), app.config.ts (providers), app.ts (root component).
Demo
The tutor runs ng new stockroom --standalone --style=css --skip-git in node/ng100/demo with the offline toolchain and reads each generated file in order, one sentence per file, ending with angular.json's build target.
Hands-on
Open node/ng100/stockroom (supplied, generated the same way). Write ANATOMY.md listing the seven key files and one line on what each does; mark which file you would change to add a global stylesheet.
Explain
Which file would you change to add a global stylesheet, and why?
Check
ANATOMY.md names the key files — artefact (T2):
seat_file1.2Strict TypeScript and strict templates8 minchecked · T3
Predict
With strictTemplates on, <p>{{ product.nam }}</p> where Product has name…
Lesson
tsconfig.json with "strict": true and angularCompilerOptions.strictTemplates makes the compiler check types in classes and templates alike. Every error it adds is one the browser would have shown you as a blank cell at runtime.
Demo
The tutor confirms strict settings in the demo, then writes a template typo and an implicit any in a handler, runs ng build, and reads both errors back to their lines.
Hands-on
Confirm strict and strictTemplates in stockroom's tsconfig.json. Add a Product type in src/app/product.ts (id, name, sku, qty, cents) and a component property typed Product[]; build clean.
Explain
What does strictTemplates catch that strict alone does not?
Check
Strict compiler settings and a Product type — checked (T3):
seat_buildseat_fileseat_file1.3ESLint as your first reviewer8 minchecked · T3
Predict
ESLint with typescript-eslint recommended flags const x = 1; never used. Build passes. Should you care?
Lesson
ESLint 10 with @eslint/js and typescript-eslint in a flat eslint.config.js: recommended rules plus a few of your own (no-explicit-any, no-unused-vars). Run eslint src; zero errors is the bar the check enforces.
Demo
The tutor writes eslint.config.js in the demo using the seat's toolchain, runs eslint src, fixes an unused import and an explicit any, and reruns to zero.
Hands-on
Create eslint.config.js in stockroom with @eslint/js recommended and typescript-eslint recommended, plus @typescript-eslint/no-explicit-any as error. Run eslint src until it reports zero errors.
Explain
Name one thing ESLint catches that tsc does not.
Check
ESLint configured and clean — checked (T3):
seat_fileseat_lint1.4Prettier and a consistent style7 minchecked · T3
Predict
prettier --check src reports one file. What does that mean?
Lesson
Prettier settles formatting so reviews talk about meaning. A .prettierrc with a few choices, prettier --write src to format, prettier --check src to verify. Let the formatter own whitespace.
Demo
The tutor adds .prettierrc to the demo, deliberately mangles indentation in a component, runs prettier --check (fails), then --write, then --check (passes).
Hands-on
Add .prettierrc (singleQuote true, printWidth 100) to stockroom, run prettier --write src, and confirm prettier --check src passes. Add a format script to package.json.
Explain
Why hand formatting to a tool?
Check
Prettier config and a format script — checked (T3):
seat_fileseat_file1.5The production build and its budgets8 minchecked · T3
Predict
angular.json budgets: initial maximumWarning 500kB, maximumError 1MB. The bundle is 600kB. Build result?
Lesson
ng build (production by default) writes dist/<app>/browser with hashed files. Budgets in angular.json warn or fail when bundles grow. Read the size table every time; it is the cheapest performance review you will ever get.
Demo
The tutor runs ng build in the demo, reads the output table, lowers the initial budget until a warning appears, then restores it, and opens dist in the App Preview.
Hands-on
Run ng build in stockroom. Set the initial bundle budgets to maximumWarning 400kB and maximumError 800kB in angular.json. Record the initial total size in ANATOMY.md.
Explain
What is a budget for?
Check
Production build passes with tightened budgets — checked (T3):
seat_fileseat_buildweb_dom1.6Lab: the tooled project with a smoke test9 minchecked · T3
Predict
ng test runs which runner in this seat?
Lesson
A smoke test proves the pipeline: render the root component with TestBed and assert the title. With lint, format, build and test all green, the project is ready for the real work; the check reads each tool's result.
Demo
The tutor writes app.spec.ts in the demo rendering App and asserting an h1, runs ng test, then runs lint, format check and build in sequence as the course's four-tool rhythm.
Hands-on
Write src/app/app.spec.ts with a test "renders the Stockroom title" asserting an h1 containing Stockroom (add the h1 to app.html). Make lint, prettier --check, ng build and ng test all pass.
Explain
List the four tools you ran and what each proved.
Check
Lint, build and smoke test all pass — checked (T3):
seat_lintseat_buildseat_testweb_dom
Session 2 · Components in depth
Six steps on the component as a unit of design. Signals as the state model; computed for derived values; effect for side effects and when not to use it. input() and input.required() with transforms; output() and typed events; model() for two-way binding. Content projection with ng-content and named slots. Lifecycle in a signals world: constructor, inject, afterNextRender. Live in the seat: a product card whose price label derives from cents, an effect logging a change, a transform turning a string input into a number, a card shell projecting any content, and a stale-value bug that disappears when computed replaces a cached field.
Lab A ProductCard component: required product input, a computed low-stock flag and formatted price, an adjust output with a typed payload, a projected footer slot, and a test that renders it. Session 2 checkpoint.
6 mini steps · ~50 min
2.1Signals, computed and the stale-field bug9 minchecked · T3
Predict
qty = signal(5); low = this.qty() < 3 (a plain field). qty.set(1). What is low?
Lesson
signal(v) holds state; computed(() => …) derives from signals and re-evaluates when they change. A plain field that copies a signal's value is a bug waiting to go stale. State in signals, everything else computed.
Demo
The tutor writes the stale-field bug in the demo card, shows the wrong label after a click, replaces the field with computed, and shows it correcting itself; then opens the computed in the debugger.
Hands-on
In stockroom create src/app/product-card.ts with product = signal<Product>({...}) for now, low = computed(() => this.product().qty < 3) and price = computed(() => (this.product().cents / 100).toFixed(2)). Render all three.
Explain
Why is a computed never stale?
Check
product-card.ts uses computed for low and price — checked (T3):
seat_buildseat_file2.2effect: side effects, sparingly8 minchecked · T3
Predict
effect(() => this.total.set(this.qty() * 2)). Is this a good use of effect?
Lesson
effect(() => …) runs when the signals it reads change: logging, saving to localStorage, syncing a third-party widget. Never set other signals inside; that is computed's job. Effects run after change detection.
Demo
The tutor adds an effect to the demo card that logs qty changes, clicks, shows the log; then shows the anti-pattern of setting a signal in an effect and the lint-like warning Angular gives.
Hands-on
Add an effect in ProductCard that logs "qty changed to N" whenever the product's qty changes. Add a button that increments qty through product.update(). Confirm the log in the console.
Explain
Give one good and one bad use of effect.
Check
An effect logs qty changes and does not set signals — checked (T3):
seat_buildseat_filecraft2.3input() and input.required() with transforms8 minchecked · T3
Predict
threshold = input(3, { transform: numberAttribute }). Parent writes threshold="5". What is threshold()?
Lesson
Inputs are signals: product = input.required<Product>(); threshold = input(3, { transform: numberAttribute }). Required inputs are compile-checked; transforms coerce attributes; computed values derive from inputs as from any signal.
Demo
The tutor converts the demo card's product signal into a required input, adds a threshold input with numberAttribute, binds both from the parent, and shows the build error when product is omitted.
Hands-on
Make product a required input and add threshold = input(3, { transform: numberAttribute }). low becomes qty < threshold(). Render two cards from App with different thresholds.
Explain
Why are inputs signals in Angular 22?
Check
Required product input and a transformed threshold input — checked (T3):
seat_buildseat_file2.4output() and model()8 minchecked · T3
Predict
adjust = output<{ id: number; delta: number }>(). The parent binds (adjust)="onAdjust($event)". Type of $event?
Lesson
output<T>() declares a typed event; emit(payload) fires it. model<T>() is an input and output pair for two-way binding with [(value)]. Children report; parents decide. Keep payloads small and explicit.
Demo
The tutor adds adjust = output<{ id; delta }>() to the demo card with plus and minus buttons, handles it in App to update the product list, then shows a model() on a small QtyStepper with [(qty)].
Hands-on
Add adjust = output<{ id: number; delta: number }>() to ProductCard with + and − buttons emitting ±1. In App keep products in a signal and handle adjust by updating the matching product immutably.
Explain
Why does the card emit a delta rather than mutate the product?
Check
Typed adjust output handled immutably by the parent — checked (T3):
seat_buildseat_fileseat_fileseat_file2.5Content projection8 minchecked · T3
Predict
<app-card><p footer>Low</p><p>Body</p></app-card> with <ng-content select="[footer]"> and a plain <ng-content>. Where does Body go?
Lesson
ng-content projects the parent's children into the component's template; select="[footer]" names a slot. A Card shell that projects content composes better than a card with ten inputs for every variation.
Demo
The tutor extracts a CardShell with a header slot, default slot and footer slot in the demo, wraps the product card content in it, and shows the same shell reused for a notice.
Hands-on
Create card-shell.ts with three ng-content slots (header, default, footer). Make ProductCard render inside CardShell with the name in header, price and qty in the body, and a projected footer from App.
Explain
When would you choose projection over inputs?
Check
CardShell projects header, body and footer — checked (T3):
seat_buildseat_fileseat_file2.6Lab: ProductCard, tested9 minchecked · T3
Predict
In a test, how do you set a required signal input on a created component?
Lesson
The card complete: required input, threshold with transform, computed low and price, typed adjust output, projected footer. A component test sets inputs via setInput, clicks, and asserts on emitted payloads and rendered text.
Demo
The tutor writes product-card.spec.ts in the demo: setInput product and threshold, assert the low class, click +, assert the emitted { id, delta: 1 }; runs ng test; then shows a failing assertion message.
Hands-on
Write src/app/product-card.spec.ts with tests "shows the formatted price", "marks low stock below the threshold" and "emits adjust with delta 1 on plus". All green with ng test; lint clean.
Explain
Why test the output payload rather than the parent's list?
Check
ProductCard tests pass and lint is clean — checked (T3):
seat_fileseat_testseat_lint
Session 3 · Template control flow and pipes
Six steps on templates that read like intent. @if/@else-if/@else; @for with track, $index and @empty; @switch for discriminated unions; built-in pipes (currency, date, number, titlecase, json) and why pure pipes are cheap; a custom pipe for cents; template variables with @let. Live in the seat: a catalogue filtered and sorted from computed signals, a status badge chosen with @switch over a union type, a currency pipe replacing a hand formatter, and a custom pipe unit-tested like a function.
Lab A Catalogue component: search and sort controls bound to signals, a computed filtered list rendered with @for and @empty, a @switch status badge, and your CentsPipe applied to every price. Session 3 checkpoint.
6 mini steps · ~49 min
3.1@if, @else if, @else7 minchecked · T3
Predict
@if (qty() === 0) { Out } @else if (qty() < 3) { Low } @else { OK } with qty 2 renders?
Lesson
Built-in control flow is part of the template language: @if (cond) { … } @else if (…) { … } @else { … }. Use @if (x(); as v) to alias a value. No imports, type-narrowed, and faster than the old structural directives.
Demo
The tutor replaces an old-style conditional in the demo catalogue with @if chains for Out/Low/OK, then shows the as-alias form narrowing a possibly undefined selected product.
Hands-on
Create src/app/catalogue.ts with a products signal (seed six, two of them low, one at zero). Render each with an @if/@else if/@else status text: Out of stock, Low stock, In stock.
Explain
What does the as alias in @if give you?
Check
@if chain renders three stock states — checked (T3):
seat_buildseat_file3.2@for: track, $index and @empty8 minchecked · T3
Predict
@for (p of products(); track $index). The list is re-sorted. What happens to the DOM rows?
Lesson
@for (p of list(); track p.id) { … } @empty { … }. track must be a stable identity (id), not $index, unless the list is static. $index, $first, $last, $even are available inside. @empty renders when the list is empty.
Demo
The tutor sorts the demo list with track $index and shows every row re-rendering in the DOM inspector, switches to track p.id and shows rows moving; adds @empty with a message.
Hands-on
Render the catalogue with @for (p of products(); track p.id), show $index + 1 as a row number, and add @empty with "No products match".
Explain
When is track $index acceptable?
Check
@for tracks by id with $index and @empty — checked (T3):
seat_buildseat_file3.3@switch over a discriminated union8 minchecked · T3
Predict
status: 'active' | 'discontinued' | 'preorder'. @switch (p.status) with cases for two of them and no @default. Third value renders?
Lesson
@switch (expr) { @case ('a') { … } @case ('b') { … } @default { … } } reads a union cleanly. Model status as a string literal union in product.ts so the cases are spelled correctly and the code that switches on it in TypeScript can be exhaustive.
Demo
The tutor adds status to the demo Product union, renders a badge with @switch and three cases, misspells a case and shows strictTemplates catching it.
Hands-on
Add status: 'active' | 'discontinued' | 'preorder' to Product; render a <span class="badge"> with @switch and a @case for each plus @default. Seed at least one product per status.
Explain
Why a literal union rather than free strings for status?
Check
status union and a @switch badge — checked (T3):
seat_buildseat_fileseat_file3.4Built-in pipes and @let8 minchecked · T3
Predict
{{ 1999 / 100 | currency:'USD' }} renders?
Lesson
Pipes transform a value for display: currency, number, date, titlecase, json for debugging. Pure pipes run only when inputs change. @let total = grand(); names a value once in the template instead of calling a function repeatedly.
Demo
The tutor replaces the demo's hand-made price string with currency and the row header with titlecase, debugs a product with json, and introduces @let for a computed total used three times.
Hands-on
Use the currency pipe for prices (cents / 100), titlecase for names in the header, and @let count = filtered().length to show "N products" twice without repeating the call. Import the pipes.
Explain
Why are pure pipes cheap?
Check
currency, titlecase and @let in the catalogue — checked (T3):
seat_buildseat_fileseat_file3.5A custom pipe, tested like a function8 minchecked · T3
Predict
CentsPipe.transform(1999) returns "19.99". Is the pipe pure?
Lesson
@Pipe({ name: 'cents' }) with transform(cents: number): string is a plain class: unit-test it with new CentsPipe().transform(…). Keep money in cents in the model and let the pipe format at the edge.
Demo
The tutor writes cents.pipe.ts and cents.pipe.spec.ts in the demo with three cases (0, 5, 123456), runs ng test, then uses the pipe in the catalogue in place of the currency expression.
Hands-on
Write src/app/cents.pipe.ts (name cents, transform(cents: number): string with two decimals and thousands grouping) and cents.pipe.spec.ts with tests for 0, 5 and 123456. Use the pipe in the catalogue.
Explain
Why test the pipe directly instead of through a component?
Check
CentsPipe with three passing tests — checked (T3):
seat_fileseat_testseat_file3.6Lab: the filtered, sorted catalogue10 minchecked · T3
Predict
filtered = computed(() => products().filter(...).sort(...)). Does sort mutate products()?
Lesson
search and sortBy signals bound to inputs with (input) and (change); filtered is a computed that filters by name or sku (case-insensitive) then sorts by the chosen key on a copy. Rows with @for and track, @empty, the @switch badge and the cents pipe.
Demo
The tutor builds the demo's search box and sort select bound to signals, writes the computed pipeline, types to filter live, and runs a component test typing into the box and counting rows.
Hands-on
Add search = signal('') and sortBy = signal<'name' | 'qty' | 'cents'>('name'), a search input and a sort select updating them, and filtered = computed(...). Write catalogue.spec.ts: "filters by search text" and "shows empty message when nothing matches".
Explain
Why is filtered a computed and not updated in the input handler?
Check
Catalogue filters and sorts via computed; tests pass — checked (T3):
seat_buildseat_fileseat_testseat_lint
Session 4 · Services and dependency injection
Six steps that move state out of components. @Injectable({ providedIn: 'root' }) and inject(); a signal-based store service with private writable signals and public readonly views; immutable updates; where RxJS still fits (events over time, debounce) and toSignal to bridge back; providing a fake in tests; a localStorage persistence service behind an interface. Live in the seat: two components sharing one store, a debounced search stream turned into a signal, and a component test that swaps the real store for a fake.
Lab An InventoryStore service owning the product list with select/add/adjust/remove, a debounced search signal from RxJS, a StorageService saving to localStorage behind an interface, and tests with a fake storage. Session 4 checkpoint.
6 mini steps · ~52 min
4.1Injectable services and inject()8 minchecked · T3
Predict
@Injectable({ providedIn: 'root' }) class Store {}. Two components inject(Store). How many instances?
Lesson
A service is a class Angular constructs for you; providedIn: 'root' makes it a singleton; inject(Store) in a field initialiser hands it to any component or other service. Components render, services own logic and state.
Demo
The tutor creates inventory-store.ts in the demo with a products signal, injects it into Catalogue and ProductCard's parent, and shows one list shared by both views.
Hands-on
Create src/app/inventory-store.ts as @Injectable({ providedIn: 'root' }) holding the seed products in a signal. Replace the local products signal in Catalogue with inject(InventoryStore).
Explain
Why keep the list in a service instead of App?
Check
InventoryStore provided in root and injected — checked (T3):
seat_buildseat_fileseat_file4.2A signal store: private write, public read9 minchecked · T3
Predict
readonly products = this._products.asReadonly(). A component calls store.products.set([]). Result?
Lesson
private _products = signal<Product[]>([]) and readonly products = this._products.asReadonly(). Methods add, adjust, remove update immutably with map/filter/spread. Derived values (lowStock, totalValue) are computed in the store.
Demo
The tutor refactors the demo store to the private/readonly pattern, moves adjust logic from App into it, adds lowStock = computed, and shows a component failing to compile when it tries to set the list.
Hands-on
Refactor InventoryStore: private _products, readonly products, methods add(p), adjust(id, delta), remove(id) with immutable updates, and computed lowStock (qty < 3) and totalValueCents.
Explain
What does asReadonly protect?
Check
Store exposes readonly products and computed views — checked (T3):
seat_buildseat_filecraft4.3Where RxJS still fits: debounce and toSignal9 minchecked · T3
Predict
search$ is a Subject<string> piped through debounceTime(300). The user types 5 letters quickly. How many values pass?
Lesson
Signals model state; RxJS models events over time. A Subject fed by (input), piped through debounceTime and distinctUntilChanged, then toSignal(obs$, { initialValue: '' }) gives a debounced search signal the computed can read.
Demo
The tutor adds search$ to the demo catalogue, debounces it, converts with toSignal, and types quickly while the filtered list updates once; shows the operator chain in one short pipe.
Hands-on
In Catalogue add private search$ = new Subject<string>(); searchDebounced = toSignal(this.search$.pipe(debounceTime(300), distinctUntilChanged()), { initialValue: '' }). Feed it from the input and use it in filtered.
Explain
Why not debounce with a signal alone?
Check
Debounced search via Subject, debounceTime and toSignal — checked (T3):
seat_buildseat_file4.4An interface and a fake for tests8 minchecked · T3
Predict
TestBed.configureTestingModule({ providers: [{ provide: StorageService, useClass: FakeStorage }] }). Who gets FakeStorage?
Lesson
Define StorageService as an abstract class with load()/save(); LocalStorageService implements it with localStorage and JSON; tests provide an in-memory fake with { provide: StorageService, useClass: FakeStorage }. Abstract classes work as DI tokens; interfaces do not.
Demo
The tutor writes storage.service.ts (abstract) and local-storage.service.ts in the demo, provides the real one in app.config.ts, and swaps in a fake inside a spec to prove the store saves on every change.
Hands-on
Create src/app/storage.service.ts (abstract class StorageService { abstract load(): Product[] | null; abstract save(p: Product[]): void }) and local-storage.service.ts implementing it. Provide it in app.config.ts.
Explain
Why an abstract class rather than an interface as the token?
Check
Abstract StorageService with a localStorage implementation provided — checked (T3):
seat_buildseat_fileseat_fileseat_file4.5Persisting the store with an effect8 minchecked · T3
Predict
The store loads from storage in its constructor; localStorage holds corrupt JSON. What should happen?
Lesson
In the store: inject(StorageService); constructor loads with try/catch and falls back to the seed; effect(() => this.storage.save(this._products())) persists every change. This is a legitimate effect: it is a side effect.
Demo
The tutor adds load-with-fallback and the save effect to the demo store, adjusts a product, reloads the preview and the change survives; then corrupts the stored JSON and shows the clean fallback.
Hands-on
Make InventoryStore load from StorageService with a try/catch fallback to the seed, and persist through an effect that calls save(). Verify a change survives a preview reload.
Explain
Why is this effect acceptable when 2.2 warned against effects?
Check
Store loads with fallback and saves through an effect — checked (T3):
seat_buildseat_filecraft4.6Lab: the store, tested with a fake10 minchecked · T3
Predict
A store test injects InventoryStore with FakeStorage provided. adjust(1, -1) runs. What should the fake have received?
Lesson
Service tests: TestBed with a fake storage, inject the store, call methods, assert on products() and on what the fake recorded. No DOM. The fake is a tiny class with arrays for saved values. Effects run after TestBed.flushEffects().
Demo
The tutor writes inventory-store.spec.ts in the demo with FakeStorage, tests add, adjust clamps at zero, remove, and "saves after every change" using flushEffects; runs ng test green, lint clean.
Hands-on
Write src/app/inventory-store.spec.ts with FakeStorage and tests "adds a product", "adjust never goes below zero", "removes by id" and "saves after a change". Clamp qty at zero in adjust if you have not.
Explain
What did the fake let you assert that the real storage would not?
Check
Store tests pass with a fake storage — checked (T3):
seat_fileseat_testseat_lint
Session 5 · Routing
Six steps on URLs as state. provideRouter and a routes array; router-outlet and routerLink; route parameters read as signals with withComponentInputBinding; a detail page that handles a missing id; lazy routes with loadComponent; a functional guard; programmatic navigation and a not-found route. Live in the seat: a list page and a detail page sharing the store, the URL changing without a reload, a lazy chunk appearing in the build output, and a guard turning away an invalid id.
Lab Stockroom routed: /products list, /products/:id detail with input-bound id, lazy /about, a guard that rejects non-numeric ids, a not-found page, and a routing test. Session 5 checkpoint.
6 mini steps · ~49 min
5.1provideRouter, routes and the outlet8 minchecked · T3
Predict
routes = [{ path: '', redirectTo: 'products', pathMatch: 'full' }, { path: 'products', component: Catalogue }]. The URL is /. What renders?
Lesson
app.routes.ts exports Routes; app.config.ts calls provideRouter(routes); App's template has <router-outlet />. The URL chooses the component. A redirect for '' and a ** wildcard for not-found complete the table.
Demo
The tutor adds routes for products and about in the demo, swaps App's content for a router-outlet and a nav with routerLink, builds, and clicks between pages while the URL changes without a reload.
Hands-on
Create src/app/app.routes.ts with '' → redirect to products, products → Catalogue, and ** → NotFound (make a small NotFound component). provideRouter in app.config.ts; router-outlet and two routerLink anchors in App.
Explain
What is the outlet?
Check
Routes with redirect, products and wildcard; outlet in App — checked (T3):
seat_buildseat_fileseat_fileseat_file5.2Route parameters as inputs8 minchecked · T3
Predict
With withComponentInputBinding(), route products/:id and component input id = input.required<string>(). URL /products/7 gives id()?
Lesson
provideRouter(routes, withComponentInputBinding()) binds route params, query params and data to component inputs by name. id = input.required<string>() then product = computed(() => store.find(Number(this.id()))). Params are strings.
Demo
The tutor adds products/:id → ProductDetail in the demo, enables input binding, reads id as an input, finds the product from the store, and shows the detail page updating when the URL changes.
Hands-on
Add ProductDetail (src/app/product-detail.ts) with id = input.required<string>() and a computed product from the store. Route products/:id to it; link each catalogue row with routerLink. Enable withComponentInputBinding.
Explain
Why is the id a string?
Check
Detail route bound to a required id input — checked (T3):
seat_buildseat_fileseat_fileseat_file5.3Handling a missing product7 minchecked · T3
Predict
store.find(999) returns undefined. The template reads {{ product().name }}. Under strictTemplates…
Lesson
A find may return undefined; say so in the type and guard in the template: @if (product(); as p) { … } @else { <p class="missing">No such product</p> }. The URL is user input; never trust it to be valid.
Demo
The tutor navigates the demo to /products/999, shows the build error without a guard, adds @if … as p with an @else message and a link back, and shows the friendly page.
Hands-on
Guard ProductDetail's template with @if (product(); as p) and an @else block containing <p class="missing"> and a routerLink back to /products. Build and visit an unknown id in the preview.
Explain
Why is a missing product a normal case, not an exception?
Check
Detail guards undefined with @if … as and an @else — checked (T3):
seat_buildseat_file5.4Lazy routes with loadComponent8 minchecked · T3
Predict
{ path: 'about', loadComponent: () => import('./about').then(m => m.About) }. When is about's code downloaded?
Lesson
loadComponent: () => import('./about').then(m => m.About) splits the component into its own chunk. The build output lists it as a lazy chunk; the initial bundle shrinks. Use it for anything not needed on first paint.
Demo
The tutor converts the demo About route to loadComponent, rebuilds, and points at the new lazy chunk row in the build table and the smaller initial size.
Hands-on
Create src/app/about.ts and route it lazily with loadComponent. Build and record the lazy chunk's name and the new initial size in ANATOMY.md.
Explain
What changed in the build output after the lazy route?
Check
About is a lazy route — checked (T3):
seat_buildseat_fileseat_file5.5A functional guard and programmatic navigation8 minchecked · T3
Predict
canActivate: [idIsNumeric]. The guard returns inject(Router).createUrlTree(['/not-found']) for 'abc'. Result?
Lesson
A functional guard is a CanActivateFn: (route) => /^\d+$/.test(route.paramMap.get('id') ?? '') || inject(Router).createUrlTree(['/not-found']). Guards validate before the component exists. inject(Router).navigate([...]) moves programmatically, e.g. after a delete.
Demo
The tutor writes id-is-numeric.guard.ts in the demo, attaches it to products/:id, tries /products/abc and lands on not-found; then adds a Delete button on the detail that removes and navigates back to the list.
Hands-on
Write src/app/id-is-numeric.guard.ts as a CanActivateFn redirecting to /not-found for non-numeric ids; add a not-found route; attach the guard to products/:id. Add a Delete button on the detail that removes then navigates to /products.
Explain
Why validate the id in a guard rather than in the component?
Check
Numeric-id guard, not-found route and navigate after delete — checked (T3):
seat_buildseat_fileseat_fileseat_file5.6Lab: the routed Stockroom, tested10 minchecked · T3
Predict
In a routing test you call router.navigateByUrl("/products/2") and then read the DOM. What must you await first?
Lesson
Routing tests use provideRouter(routes) in TestBed, a RouterTestingHarness or the Router directly, navigate, await, and read the DOM. Three tests: list renders rows, /products/2 renders the detail name, /products/abc lands on not-found.
Demo
The tutor writes app.routing.spec.ts in the demo with RouterTestingHarness: navigates to each URL, asserts the rendered component's text, including the guard redirect; runs ng test and lint.
Hands-on
Write src/app/app.routing.spec.ts with tests "list route renders product rows", "detail route renders the product name" and "non-numeric id redirects to not-found". All green; lint clean; build passes.
Explain
What does the guard test prove that the unit test of the guard function does not?
Check
Routing tests pass — checked (T3):
seat_buildseat_testseat_lintweb_dom
Session 6 · Forms
Six steps on typed reactive forms done properly. FormBuilder.nonNullable and inferred types; built-in validators; a custom validator for SKU format; cross-field validation at the group level; error messages driven by a small helper; patching a form from a route-loaded product and saving back through the store; disabling while submitting. Live in the seat: a form whose value type is inferred, a SKU validator rejecting lower-case, a min-less-than-max rule on the group, and the edit page round-tripping a product.
Lab A ProductEdit page at /products/:id/edit: a nonNullable FormBuilder form with name, sku (custom validator), qty and cents, a group-level rule, error messages, patched from the store and saved back; tested. Session 6 checkpoint.
6 mini steps · ~50 min
6.1FormBuilder.nonNullable and inferred types8 minchecked · T3
Predict
form = inject(FormBuilder).nonNullable.group({ qty: [0] }). form.getRawValue().qty has type?
Lesson
const fb = inject(FormBuilder).nonNullable; form = fb.group({ name: [''], sku: [''], qty: [0], cents: [0] }). The value type is inferred from the initial values; getRawValue() returns it fully typed; reset() returns to the initial values.
Demo
The tutor creates product-edit.ts in the demo with a nonNullable group, hovers the inferred type, binds four inputs with formControlName, and logs getRawValue() on submit.
Hands-on
Create src/app/product-edit.ts with a nonNullable FormBuilder group for name, sku, qty, cents and a template with four inputs (number type for qty and cents) and a submit button. Route it at products/:id/edit.
Explain
What does nonNullable change about reset()?
Check
nonNullable FormBuilder form with four controls, routed — checked (T3):
seat_buildseat_fileseat_file6.2Built-in validators and messages8 minchecked · T3
Predict
qty: [0, [Validators.required, Validators.min(0)]]. The user types -1. form.controls.qty.errors is?
Lesson
Validators go in the control's second array slot: required, minLength, min, max, pattern. errors is null or a keyed object. A tiny errorMessage(control) helper maps keys to sentences so templates stay clean.
Demo
The tutor adds required and minLength(2) to name, required and min(0) to qty and cents, writes a messages helper and shows messages appearing after blur in the demo form.
Hands-on
Add validators: name required + minLength(2); qty and cents required + min(0). Write src/app/form-messages.ts exporting errorMessage(control: AbstractControl): string. Show messages when touched and invalid.
Explain
Why centralise error text in a helper?
Check
Validators and an errorMessage helper — checked (T3):
seat_buildseat_fileseat_file6.3A custom validator8 minchecked · T3
Predict
skuValidator returns null for "AB-1234" and { sku: true } for "ab-1234". What does null mean?
Lesson
A ValidatorFn is (control) => ValidationErrors | null. skuValidator checks /^[A-Z]{2}-\d{4}$/. It is a pure function: test it directly with new FormControl('x') before wiring it into the form.
Demo
The tutor writes sku.validator.ts and sku.validator.spec.ts in the demo (valid, lower-case, wrong length), runs ng test, then adds it to the sku control and shows the message.
Hands-on
Write src/app/sku.validator.ts exporting skuValidator: ValidatorFn for ^[A-Z]{2}-\d{4}$ and sku.validator.spec.ts with three tests. Add it to the sku control and a message "SKU looks like AB-1234".
Explain
Why return an object rather than throw?
Check
skuValidator with three passing tests — checked (T3):
seat_fileseat_testseat_file6.4Cross-field validation on the group8 minchecked · T3
Predict
A group validator returns { reorderAboveQty: true }. Which control shows the error in form.controls.x.errors?
Lesson
Rules across fields live on the group: fb.group({...}, { validators: [reorderBelowQty] }) where the validator reads two controls and returns an error on the group. Add a reorderAt control; the rule: reorderAt must not exceed qty.
Demo
The tutor adds reorderAt to the demo form, writes the group validator, and shows form.errors appearing when reorderAt is higher than qty, with the message rendered under the form.
Hands-on
Add reorderAt: [0] to the form and a group validator reorderBelowQty in src/app/reorder.validator.ts that sets { reorderAboveQty: true } when reorderAt > qty. Render the group error.
Explain
Why does a two-field rule belong on the group?
Check
Group-level reorderBelowQty validator — checked (T3):
seat_buildseat_fileseat_fileseat_file6.5Patch from the store, save back8 minchecked · T3
Predict
form.patchValue(product) where product has an extra field id. What happens to id?
Lesson
The edit page reads id as a route input, finds the product in the store and patchValue()s the form in an effect or constructor. save() guards invalid, builds the updated Product from getRawValue() plus the id, calls store.update(product) and navigates back.
Demo
The tutor patches the demo form from the store, edits qty, saves, and the catalogue shows the new value; then shows setValue failing where patchValue passed.
Hands-on
Add update(p: Product) to InventoryStore. In ProductEdit read id, patch the form from the store product, and save() → store.update({ ...getRawValue(), id }) → navigate to /products/:id. Disable the button while form.invalid.
Explain
Why rebuild the Product from getRawValue() plus id?
Check
Edit page patches from the store and saves back — checked (T3):
seat_buildseat_fileseat_file6.6Lab: ProductEdit, validated and tested10 minchecked · T3
Predict
A test sets sku to "ab-1234" and clicks Save. What must the store have received?
Lesson
The edit page complete: nonNullable form, built-in, custom and group validators, messages, patch and save. Tests render with provideRouter and a seeded store, fill controls through setValue on the form, click Save, and assert the store.
Demo
The tutor writes product-edit.spec.ts in the demo: "patches the form from the store", "rejects an invalid sku", "saves a valid edit to the store"; runs ng test, lint and build.
Hands-on
Write src/app/product-edit.spec.ts with those three tests, using FakeStorage from session 4 and setInput for id. All green, lint clean, build passes.
Explain
Which layer did each test exercise?
Check
ProductEdit tests pass; lint and build clean — checked (T3):
seat_testseat_lintseat_build
Session 7 · Testing
Six steps that make tests a habit rather than a chore. How ng test runs Vitest with jsdom; TestBed and the fixture; querying the DOM the way a user sees it; faking async with fakeAsync/tick for debounced inputs; testing services and stores in isolation; coverage with @vitest/coverage-v8 and a sensible floor. Live in the seat: a flaky test fixed by awaiting stability, a debounce tested in zero real milliseconds, a coverage report opened in the preview, and an untested branch found and covered.
Lab Raise Stockroom's test suite to cover every component and service with at least 80% line coverage, including a fakeAsync test for the debounced search; coverage enforced by the check. Session 7 checkpoint.
6 mini steps · ~50 min
7.1How ng test runs Vitest7 minchecked · T3
Predict
Where does the DOM come from when ng test runs in this seat?
Lesson
angular.json's test target uses the Vitest builder; a vitest config can set the environment, reporters and coverage. ng test --watch=false runs once; --reporter=json writes the report the check reads. Know your runner before you trust it.
Demo
The tutor opens the demo's angular.json test target, runs ng test --watch=false, then with a JSON reporter, and opens the report to show the test names the check matches.
Hands-on
Run ng test --watch=false in stockroom and read the summary. Add a test script to package.json that runs it once. Note the number of tests in ANATOMY.md.
Explain
Why does the check care about test names?
Check
A test script and the suite runs — checked (T3):
seat_fileseat_test7.2Querying like a user8 minchecked · T3
Predict
Which query is most resilient to a CSS refactor?
Lesson
Query by role, label and text rather than by class or position: querySelector('button[aria-label="…"]'), label text, headings. Tests that read like a user story break only when behaviour changes. Add the aria labels you wish you had.
Demo
The tutor rewrites two brittle selectors in the demo specs to aria-label and text-based queries, adds the labels to the templates, and shows the tests surviving a class rename.
Hands-on
Add aria-label attributes to the Add, Save and Delete buttons across Stockroom and rewrite your existing tests to query by them. No test may select by a class that starts with btn.
Explain
What else did adding aria-labels improve?
Check
Tests query by aria-label, not by button classes — checked (T3):
seat_fileseat_fileseat_fileseat_test7.3fakeAsync and tick for debounced input9 minchecked · T3
Predict
Inside fakeAsync, you type into a debounced (300 ms) search and read rows immediately. Rows are…
Lesson
fakeAsync(() => { … tick(300); fixture.detectChanges(); … }) tests time without waiting. Dispatch the input event, tick past the debounce, detect changes, assert. Clean up pending timers with discardPeriodicTasks or flush when needed.
Demo
The tutor writes a fakeAsync test in the demo for the debounced search: types "bolt", asserts unchanged rows, ticks 300, asserts filtered rows; runs it in milliseconds.
Hands-on
Add to catalogue.spec.ts a fakeAsync test "debounces the search by 300 ms": type, assert rows unchanged, tick(300), detectChanges, assert filtered. Make it pass.
Explain
Why is fakeAsync better than a real 300 ms wait?
Check
A fakeAsync debounce test passes — checked (T3):
seat_fileseat_test7.4Testing a service in isolation8 minchecked · T3
Predict
it.each([[5, -10, 0], [5, 2, 7]])("adjust %i by %i gives %i", …). How many tests run?
Lesson
A store test needs TestBed only for injection (or none at all when you construct it with a fake). it.each turns a table of cases into named tests. Test the rule (clamp at zero, totals) once per interesting case, not once per click.
Demo
The tutor adds an it.each table to the demo store spec for adjust edge cases and one for totalValueCents, shows the generated test names in the report.
Hands-on
Add to inventory-store.spec.ts an it.each table "adjust %i by %i gives %i" with at least four rows including a clamp case, and a test for totalValueCents over three products.
Explain
What does a table test make obvious?
Check
Table-driven store tests — checked (T3):
seat_fileseat_test7.5Coverage as a floor8 minchecked · T3
Predict
Line coverage is 95% but the clamp-at-zero branch is never tested. Is the store well tested?
Lesson
ng test --coverage with @vitest/coverage-v8 writes coverage/ with an HTML report and lcov. Set a floor (80% lines) so it cannot silently drop; read the red lines in the report to find untested branches.
Demo
The tutor runs coverage in the demo, opens coverage/index.html in the App Preview, finds a red @else branch in the detail component, writes the test, and reruns to green.
Hands-on
Run ng test --coverage in stockroom. Open the HTML report and find one uncovered branch; write a test that covers it. Record the line percentage in ANATOMY.md.
Explain
What did the red lines tell you?
Check
Coverage report produced, ANATOMY.md records it — checked (T3):
seat_coverageseat_file7.6Lab: 80% and every unit covered10 minchecked · T3
Predict
You add tests until lines reach 80%. Which untested thing would coverage still miss?
Lesson
Every component and service in Stockroom has a spec; the debounce has a fakeAsync test; the store has tables; lines are 80% or better. The check reads the coverage summary and the test report; the habit is yours from here.
Demo
The tutor fills the last gaps in the demo (NotFound, About, CardShell projection), reruns coverage at 85%, and shows the check passing with the thresholds.
Hands-on
Add specs for NotFound, About and CardShell (projection renders header and footer). Reach at least 80% lines. All tests pass; lint clean.
Explain
How will you keep coverage from decaying?
Check
Coverage >= 80% lines, all specs pass — checked (T3):
seat_coverageseat_fileseat_fileseat_testseat_lint
Session 8 · Capstone: Stockroom
Six steps that finish the application. Reading the spec and planning routes, store and forms; a New Product page reusing the edit form; JSON export through a Blob download and import through a file input with validation of every record; a dashboard with computed totals and low-stock list; a final accessibility pass; then the hidden tests. Live in the seat: an import file with a corrupt record rejected with a clear message, a dashboard following the store, and the grader's hidden spec importing an inventory the learner never saw.
Lab Stockroom complete: list, detail, new and edit pages, dashboard, JSON import with validation and export, tests for all of it, 80% coverage; then the hidden tests over an inventory you did not see. Course placement step. Session 8 checkpoint.
6 mini steps · ~55 min
8.1Spec to plan8 minartefact · T2
Predict
The spec says "import must reject the whole file if any record is invalid". Where does that rule live?
Lesson
Read node/ng100/samples/s8/STOCKROOM-SPEC.md. Plan: routes (products, products/new, products/:id, products/:id/edit, dashboard), store methods (add, update, remove, replaceAll), a pure inventory-io.ts (parse/validate/serialize). Write PLAN.md first.
Demo
The tutor reads the spec, draws the route table and the module boundary between inventory-io.ts (pure) and the store, and writes PLAN.md with the test list for each.
Hands-on
Write stockroom/PLAN.md: route table, store methods, the pure inventory-io functions with their signatures, and the names of the tests you will write for each.
Explain
Which part of your plan has no Angular in it, and why is that good?
Check
PLAN.md covers routes, store and inventory-io — artefact (T2):
seat_file8.2New Product: reuse the form9 minchecked · T3
Predict
ProductForm emits save with the typed value; ProductNew and ProductEdit wrap it. Who assigns the new id?
Lesson
Extract ProductForm (inputs: initial value; output: save) from ProductEdit so both New and Edit reuse it. The store's add() assigns the next id. Route products/new before products/:id so "new" is not taken as an id.
Demo
The tutor extracts product-form.ts from the demo edit page, builds ProductNew around it, orders the routes, and shows the guard not swallowing /products/new.
Hands-on
Create src/app/product-form.ts (input initial, output save) and product-new.ts using it; make ProductEdit use it too. Route products/new ahead of products/:id. Add a New product link on the list.
Explain
Why must products/new come before products/:id?
Check
Shared ProductForm with New and Edit pages — checked (T3):
seat_buildseat_fileseat_fileseat_file8.3inventory-io: parse, validate, serialize10 minchecked · T3
Predict
parseInventory("[{\"id\":1,\"name\":\"Bolt\"}]") — the record lacks sku, qty, cents. Result?
Lesson
inventory-io.ts: parseInventory(text): { ok: true; products: Product[] } | { ok: false; error: string } validating every field's type and range (qty ≥ 0, cents ≥ 0, sku format, status union); serializeInventory(products): string. Tests first, with good, missing-field, wrong-type and bad-JSON inputs.
Demo
The tutor writes inventory-io.spec.ts with five cases in the demo, then the implementation with a guard per field, runs green, and shows the error message for a corrupt record.
Hands-on
Write src/app/inventory-io.spec.ts (≥5 cases) and inventory-io.ts with parseInventory and serializeInventory as described. No any; a discriminated result type. Round-trip test: serialize then parse equals the input.
Explain
Why a result type instead of throwing?
Check
inventory-io parses, validates and serializes with tests — checked (T3):
seat_fileseat_testcraft8.4Import and export in the browser9 minchecked · T3
Predict
The import file fails validation. What should the store's products be afterwards?
Lesson
Export: serializeInventory → new Blob → URL.createObjectURL → a temporary anchor click. Import: <input type="file">, (change) reads the file as text, parseInventory, then store.replaceAll on success or an error message on failure.
Demo
The tutor adds an ImportExport component to the demo, exports, edits the file to break a record, imports it, and shows the error with the inventory untouched; then imports a good file.
Hands-on
Create src/app/import-export.ts with an Export button and a file input labelled Import; wire parseInventory and store.replaceAll(products); show <p class="import-error"> on failure. Add replaceAll to the store.
Explain
Why read the file to text first instead of JSON.parse directly?
Check
Import/export component wired to inventory-io and the store — checked (T3):
seat_buildseat_fileseat_file8.5Dashboard and the accessibility pass9 minchecked · T3
Predict
The dashboard shows totalValueCents and lowStock. Where are they computed?
Lesson
Dashboard reads store.products().length, store.totalValueCents() through the cents pipe, and store.lowStock() as a list with links. Then the pass: every input labelled, buttons named, one h1 per page, focus visible, the table has headers.
Demo
The tutor builds the demo dashboard in five minutes, then runs through the accessibility checklist with keyboard only, fixing a missing label and an unlabeled icon button.
Hands-on
Create src/app/dashboard.ts at /dashboard with product count, total value (cents pipe) and the low-stock list with routerLinks. Add labels to every input (label for / aria-label) across the app; one h1 per page.
Explain
Name two accessibility fixes you made.
Check
Dashboard reads store computeds; inputs are labelled — checked (T3):
seat_buildseat_fileseat_fileseat_file8.6Lab: Stockroom proven10 minproven · T4
Predict
The hidden test imports an inventory with 37 products and a corrupt 38th. What should the dashboard count read after?
Lesson
Stockroom done: routes, store, forms, io, dashboard, 80% coverage, your tests green. The grader adds a hidden spec that drives import with inventories you never saw, checks totals on the dashboard, the rejection path and the edit round-trip.
Demo
The tutor runs the full demo suite with coverage, then an example hidden spec from a past term, showing it import a large inventory and read the dashboard, and explains that the real file differs.
Hands-on
Make sure import-export.spec.ts, dashboard.spec.ts and product-new.spec.ts exist and pass; coverage ≥ 80%; lint clean; build passes. Ask the tutor to run the capstone check.
Explain
Which design decision made the hidden import test easy to pass?
Check
Stockroom passes its own and the hidden tests at 80% coverage — proven (T4):
seat_buildseat_lintseat_coverageseat_fileseat_testweb_dom
Outcome
By the end of the course the tutor's checks will have verified: a building, linting component or service for every hands-on step; eight session labs that each grow the last — the tooled project, the product card with projection, the filtered catalogue, the signal store service, the routed list and detail, the validated edit form, the tested service and components; and the Stockroom capstone, whose routes, forms, store and JSON import/export pass hidden tests over inventories you did not see.
Certificate
Agile System Software Certificate — Angular I (NG100) attests that you can structure, build, route, validate and test an Angular 22 application with standalone components, signals, services and typed forms, verified by a machine from your own seat.
To earn it:
- Pass the lab check of every session (steps 1.6 to 7.6) on your own code.
- Pass the Stockroom capstone check (8.6) including the hidden Vitest tests the grader supplies.
- Score 80% or better on the 25-question reading quiz.
Gates (step · tier required):
Plus a 25-question reading quiz at 80%, retake after 48 h. Certificates are issued once the seat check runner is live; until then every step records what the engine saw.
Environment
A VS Code seat in your browser with Node 22, TypeScript 6, Angular 22, Vitest with jsdom and coverage, ESLint and the App Preview; all offline, nothing to install. Your work lives in node/ng100; the tutor's demo files live apart from yours and the tutor never edits your hands-on file while you are working on it.
Open Coding Tutor at codingtutor.agilesystemsoftware.com, pick NG100 from the course button in the chat and press Tutor me. Every lesson, demo and hands-on step is also published below.