Skip to content

Angular Deep Dive — Revision Sheet

DI Hierarchy

  • Injector tree mirrors the component tree. Requesting a dependency bubbles up the tree until a provider is found.
  • Multiple injector trees run in parallel: environment injectors (root/module — providedIn: 'root') and element injectors (component-level, via providers: [...] in @Component).
  • Component-level providers = new instance per component instantiation, not shared app-wide. This is NOT the same as .NET's per-HTTP-request scope — it's per-component-subtree, per-instantiation.
  • Resolution order: own element injector → walk up ancestor element injectors → environment injector (root) → NotFoundError unless @Optional().
  • Modifiers: @Self() (don't bubble), @SkipSelf() (skip own, start from parent — classic parent-child aggregator pattern), @Host() (stop at host boundary), @Optional() (inject null instead of throwing).

Providers

A provider = a recipe: token (the key) + instructions to produce the value.

  • useClass{ provide: OrderRepository, useClass: IndexedDbOrderAdapter } — DIP in action, port + adapter.
  • useValue{ provide: API_BASE_URL, useValue: 'https://...' } — hand out a literal, no construction.
  • useFactory — run a function to produce the value; deps array lists what the factory itself needs, resolved and passed positionally.
  • useExisting — alias one token to another's resolved value, no new instance.
  • @Injectable({ providedIn: 'root' }) is shorthand for { provide: X, useClass: X } at root scope.
  • provideRouter(), provideHttpClient() are functions that return arrays of providers bundled for convenience — not providers themselves.

Interface Erasure — why abstract class / InjectionToken, not interface

  • TypeScript interface is compile-time only — fully erased in compiled JS output. Nothing left at runtime to use as a DI lookup key.
  • abstract class compiles to a real JS class — exists at runtime, usable as a token. Standard way to write a "port" in Angular.
  • InjectionToken<T> — purpose-built runtime key for cases with no natural class (e.g. a plain settings shape). The <T> generic is compile-time only, for type-checking what the token resolves to.
  • Naming convention: PascalCase (AppSettings) = the type/shape. SCREAMING_SNAKE_CASE (APP_SETTINGS) = the actual runtime InjectionToken value. Two separate declarations, different roles — TS allows type names and value names to coexist without collision.

useFactory Worked Example — online/offline API switch

{
  provide: OrderApi,
  useFactory: (settings, http, indexedDb) =>
    settings.useLocalApi ? new LocalOrderApi(indexedDb) : new RemoteOrderApi(http),
  deps: [APP_SETTINGS, HttpClient, IndexedDbService]
}
  • deps is the only thing that does real runtime work — each entry is a real token, resolved by the injector, passed positionally into the factory function as arguments.
  • Type annotations on the factory's parameters (settings: AppSettings) are decorative only — pure dev-time type safety/autocomplete, erased at compile time, never consulted by the injector. Deleting the AppSettings interface entirely would not break this code at runtime. Deleting the APP_SETTINGS token would.
  • Factory runs once, result is cached for the injector's lifetime. Wrong tool if the decision needs to be re-evaluated per-call (e.g. live connectivity check) — for that, inject both adapters + a checker into a wrapper service instead, and decide per-call inside it.

Bootstrapping Flow

  1. index.html loads, <app-root> is empty, main.js (the bundled output) loads.
  2. main.ts runs, calls bootstrapApplication(AppComponent, appConfig).
  3. appConfig's providers array builds the root environment injector.
  4. Angular resolves AppComponent's deps, constructs it, mounts into <app-root>.
  5. Router (if provideRouter configured) matches the URL against route config, may trigger lazy-loading (separate bundle chunk).
  6. Route component's deps resolve (walking injector tree), constructor runs, ngOnInit fires — this, not the constructor, is where async bootstrap work (e.g. IndexedDB read) belongs.
  7. Change detection takes over as the ongoing loop from here.

One-liner: index.htmlmain.tsbootstrapApplication → root injector from app.config.tsAppComponent mounted → router activates matched route → dependencies resolve → lifecycle hooks fire → change detection running from here on.

Bundling — what bundlers actually do

  1. Module resolution — walk every import from the entry point (main.ts), build the full dependency graph.
  2. Tree-shaking — drop exports that are never imported anywhere (dead code elimination), including unused parts of libraries like RxJS.
  3. Bundling — concatenate many files into few, avoiding a waterfall of network requests.
  4. Minification — strip whitespace, shorten names.
  5. Code-splitting — lazy-loaded routes go into separate chunks, fetched only when navigated to.
  6. Source maps — let devtools show original TS despite running minified JS.

  7. Webpack — bundles everything upfront, even in dev mode (slower dev server startup).

  8. Vite / esbuild — dev mode serves source as native ES modules directly to the browser, transforming on demand; full bundle pipeline still runs for production builds. Angular 17+ uses esbuild directly via its own CLI builder ("builder": "@angular-devkit/build-angular:application" in angular.json) — not Vite itself (Vite uses Rollup for prod builds + esbuild for dev pre-bundling; Angular's builder is a separate esbuild-based pipeline).

Project Structure Reference

src/app/
  app.config.ts         providers (~ Program.cs builder.Services.Add...())
  core/services/         providedIn:'root' singletons
  shared/                 dumb/presentational components, pipes, directives
  features/<domain>/     smart components + domain services
  domain/ports/           abstract class "interfaces"
  domain/adapters/        concrete implementations of ports
main.ts                  bootstrap entry (~ Program.cs)
angular.json              build/serve/test config (~ .csproj)
package.json / package-lock.json  deps + exact pinned versions (~ .csproj PackageReference / packages.lock.json)
  • package-lock.json is committed to git; node_modules/ is not (gitignored). npm ci (used in CI) requires lock file and package.json to agree exactly and refuses to silently update — npm install will update the lock file if out of sync.
  • angular.json controls build entry/output, style/asset bundling, and environment file replacement at build time (environment.tsenvironment.prod.ts) — same spirit as appsettings.Development.json vs appsettings.Production.json, but done via CLI file-swap rather than runtime config layering.

Polyfills, Zone.js, and What's Replacing It

  • Polyfills — patch missing browser APIs so older/varied browsers behave consistently. Loaded before app code runs.
  • Zone.js — monkey-patches every async browser API (setTimeout, Promise.then, addEventListener, XHR) so Angular gets notified whenever anything async completes, anywhere. Can't tell Angular what changed — only that something did — so default change detection re-checks the entire component tree on every async event. OnPush is the escape hatch from this blanket re-check.
  • Signals replace this: each signal knows exactly when it changed (.set() call is itself the notification), so Angular updates only what actually depends on that signal — no blanket re-check needed.
  • Zoneless Angular (provideZonelessChangeDetection()) — becomes viable once enough of the app is signal-based that Zone.js's blanket async-sniffing is no longer needed. Direction the framework is actively moving.

Guards vs Resolvers

Guard Resolver
Question Can this navigation happen? What data does this route need before showing?
Can block navigation? Yes (false/UrlTree) No — navigation already proceeding
Runs relative to component construction Determines if it happens Determines when it happens (waits for data)
  • CanActivate — gate on entering a specific route.
  • CanActivateChild — same gate, applied once to cover an entire child subtree (avoids repeating the same guard on every child route).
  • CanDeactivate — the only guard that runs on exit, not entry. Receives the actual component instance, can call methods on it (e.g. hasUnsavedChanges()) to decide whether to block leaving.
  • CanMatch — runs during route matching itself, not after. If false, the router treats the route as not a match and falls through to try another route config with the same path (e.g. feature-flagged new vs legacy flow at the same URL). CanActivate can only block/redirect, not offer a fallback route.
  • Resolvers are somewhat out of favor in newer guidance — they block the whole navigation (URL doesn't change, back button behaves oddly) until data arrives; letting the component's own ngOnInit/signals trigger the fetch with a loading state is often preferred UX.

Guard/Resolver Function Parameters — DI or Not?

  • route, state, component (in CanDeactivate) — plain function arguments, supplied directly by the router. Not DI.
  • Anything pulled via inject() inside the function body — is DI, same resolution mechanism as constructor injection, different syntax because guards/resolvers are plain functions with no constructor. Works because the router runs these functions inside an injection context.
  • Same underlying split as useFactory's deps array + positional params — just a different syntactic channel (inject() on demand vs. deps declared up front).

Route vs ActivatedRoute

Route ActivatedRoute
What Static config object Live runtime instance for the current navigation
Where Your routes.ts array Injected into the component
Real values? No — :id is a pattern Yes — actual matched value, e.g. '42'
Lifetime Always exists Only while that route is active
  • ActivatedRouteSnapshot (seen in guard signatures) is a frozen point-in-time copy, used in guards/resolvers because navigation hasn't committed yet — no live instance exists to inject.

Component State — the different flavors

  1. Plain instance fields — Angular has no idea they changed unless something (Zone.js) tells it to re-check.
  2. Signalssignal(), computed() — the value itself announces its change; targeted re-render.
  3. @Input() — state handed down from parent. Under OnPush, only a reference change triggers re-check — mutating a nested property in place is invisible. Newer signal-based inputs: input<T>().
  4. @Output()EventEmitter, child notifies parent of a state change; not state itself, the mechanism for pushing it upward.
  5. Component-scoped DI-provided services — state held in an injected service scoped to a component subtree (from DI hierarchy section); destroyed with the component (ngOnDestroy cascades).
  6. Smart vs dumb components — smart owns state + injects services (e.g. OrderTabComponent); dumb only receives via @Input()/emits via @Output(), owns no business state (e.g. OrderLineComponent).

Lifecycle Hooks — full order

On creation, once:

constructor()
   ngOnChanges()   [only if @Input()s exist; fires before ngOnInit too]
   ngOnInit()
   ngDoCheck()
   ngAfterContentInit()
   ngAfterContentChecked()
   ngAfterViewInit()
   ngAfterViewChecked()

On every subsequent change detection cycle:

ngOnChanges() [if inputs changed]  ngDoCheck()  ngAfterContentChecked()  ngAfterViewChecked()

On destruction, once: ngOnDestroy()

  • constructor() — DI resolution only. No async work, no reading @Input() (not bound yet).
  • ngOnChanges(changes: SimpleChanges) — fires on @Input() reference change; gives old vs new value. This is the actual mechanism behind the OnPush mutation bug — mutate in place, no new reference, ngOnChanges never fires, OnPush never re-renders.
  • ngOnInit() — correct place for initial data fetching. DI fully resolved, initial inputs set.
  • ngDoCheck() — runs on every CD cycle, for any reason. Escape hatch for changes Angular can't otherwise detect (e.g. in-place mutation). Expensive — rarely used in practice.
  • ngAfterContentInit/Checked — about projected content (<ng-content>), not regular @Input().
  • ngAfterViewInit/Checked — about the component's own rendered template/children. @ViewChild() is undefined before ngAfterViewInit.
  • ngOnDestroy()the most important one to get right. Unsubscribe here or leak: subscription outlives the component, callback still fires, component can't be garbage collected.

Subscription Cleanup

  • Manual: store the Subscription, call .unsubscribe() in ngOnDestroy().
  • takeUntilDestroyed(destroyRef?) — modern idiom. Needs an injection context to call with no argument (works implicitly in constructor / field initializers). Outside an injection context (e.g. inside ngOnInit), capture DestroyRef as a field first (private destroyRef = inject(DestroyRef)), pass it explicitly.
  • toSignal(obs$, { initialValue }) — subscription lifecycle and change-detection notification both handled automatically; no manual unsubscribe, no manual field-assignment-then-hope-Zone.js-notices.
  • Manual subscribe() inside ngOnInit with zero cleanup is a smell even when technically safe (e.g. an HTTP call that completes on its own) — it throws away composability and doesn't demonstrate the reactive-composition tools that exist specifically to make lifecycle automatic.

Subjects

  • A Subject is simultaneously an Observable (.subscribe()-able) and an Observer (.next()/.error()/.complete() callable) — you manually push values in, whenever your own code decides to, rather than a fixed producer function baked in at creation (the plain-Observable model).
  • subscribe() on a Subject does nothing but register a listener — it does not trigger anything, because there's no producer function to run. .next() is what actually walks the list of registered subscribers and calls each of their handlers — this is the "active" step, inverted from plain Observable where subscribe itself is the trigger.
  • Multicast (Subject: one shared execution, every subscriber gets the same value at the same time) vs unicast (plain Observable: producer function reruns independently per subscriber).
  • BehaviorSubject — requires an initial value; any new subscriber immediately gets the current value. Natural fit for modeling live state (pre-Signals, this was the standard pattern; Signals are the evolution of the same idea with automatic dependency tracking).
  • ReplaySubject(n) — no initial value required; buffers the last n values, replays them to late subscribers.
  • AsyncSubject — only emits its final value, only after .complete(). Rare.
  • When to use a Subject at all: when you are the producer — no existing Observable already represents the trigger (a raw DOM event you're wiring by hand, an in-app event bus, a manual refresh trigger). Don't wrap something already Observable (valueChanges, HttpClient responses, ActivatedRoute.paramMap, fromEvent) in a Subject — pipe directly onto the existing one.

Operator Chain Mechanics — how .next() actually propagates

  • Calling .next(value) on the source actively pushes the value through every operator in the pipe, in order, synchronously as part of that call (or asynchronously once an operator's own timer/internal logic decides to forward it — e.g. debounceTime).
  • Every operator in a .pipe() chain is simultaneously an Observer of the operator before it and an Observable (with its own internal, hidden .next()) to the operator after it.
  • subscribe() at the very end is just the last link — the one place a plain callback receives the final value instead of another operator.
  • Concrete trace for debounceTime → filter → distinctUntilChanged → switchMap: .next() call arrives → debounceTime starts/resets a timer, holds the value → timer fires independently → forwards to filter → passes/fails the predicate → if passed, forwards to distinctUntilChanged → compares to last passed value → if different, forwards to switchMap → cancels any prior inner Observable, subscribes to the new one → inner Observable resolves (async) → forwards downstream → reaches subscribe().

Search-as-you-type / Race Conditions

  • firstValueFrom — converts Observable to Promise, resolves with first emission, auto-unsubscribes. Valid for genuinely one-shot, non-repeatable triggers (e.g. fetch config once at bootstrap, or inside a guard/resolver which by definition runs once per navigation). Wrong tool the moment a user can trigger the same action again before the first resolves — no cancellation, second-click race means whichever response arrives last wins regardless of which the user meant to see. Also no cleanup if the component is destroyed mid-request.
  • switchMap — cancels the previous in-flight inner Observable the moment a new value arrives. Structurally prevents the stale-overwrite race, rather than relying on remembering to guard against it.
  • Full search-as-you-type pipe: debounceTime(300) → filter(length check) → distinctUntilChanged() → switchMap(→ http call).
  • Reactive Forms' valueChanges is already an Observable — no manual Subject/DOM-event-wiring needed if using FormControl; only build a manual Subject bridge (listening to raw (input) events) when there's no FormControl available.
  • Loading spinner pitfalls:
  • tap() before switchMap, tap() after to reset — broken: if switchMap cancels the first inner Observable, its "reset loading" tap never runs (the Observable was killed, not completed) → loading state can get stuck true.
  • finalize() — fixes the stuck-forever case (runs on completion, error, or unsubscription/cancellation) but can still cause a brief flicker (loading flips false from the cancelled request, then true again from the new one). Reasonable, production-acceptable trade for most cases.
  • startWith({ loading: true, ... }) inside the switchMap's inner pipe + a single combined state object (loading + result together) — the actually airtight version; avoids both the stuck state and the flicker, at the cost of more structure.
  • Calibration point: don't reach for switchMap/cancellation machinery by default — it's overkill for triggers that can't realistically double-fire meaningfully (e.g. many button clicks). Reaching for the heaviest pattern regardless of fit is itself a smell; knowing when the ceremony earns its keep (real concurrency — background sync, network flapping) vs when six lines of plain subscribe() + signals suffices is the actual signal of depth.

Reactive Forms

  • FormControl — single input's value + state (touched/dirty/valid) + .valueChanges Observable.
  • FormGroup — named collection of controls treated as one unit; .valid is true only if every child is valid; own .valueChanges emits the whole object on any child change.
  • FormArray — dynamic list of controls when the count isn't known upfront (e.g. order line items UI) — .push(), .removeAt(index).
  • FormBuilder (fb.group({...})) — shorthand for constructing the same FormGroup/FormControl tree; each entry is [initialValue, syncValidators, asyncValidators].
  • Validators — plain functions: (control: AbstractControl) => ValidationErrors | null. null = valid; error object (arbitrary keys) = invalid. Custom validators plug into the same array as built-ins — no special registration.
  • Validator factories — a function that takes config and returns the actual validator function (same shape as useFactory from DI) — needed when the validator itself needs a parameter (e.g. a disallowed-characters string).
  • Cross-field validation — put the validator on the FormGroup (second argument to fb.group), not an individual control, since a single-field validator can't see sibling fields.
  • Async validators — return Observable<ValidationErrors | null>; go in the third array position, not mixed with sync validators; must take(1) or otherwise complete after one emission. Same switchMap-cancellation reflex as search-as-you-type.
  • Error message display — the validator only returns a key (optionally with data attached, e.g. { minlength: { requiredLength, actualLength } }); the human-readable message text lives separately.
  • Simplest: inline @if (control.hasError('key')) per field — repetitive at scale.
  • Centralized key-map pattern: VALIDATION_MESSAGES: Record<string, (error) => string> keyed by the exact error key each validator returns — one place translating "which validator failed" into "what to show." Add a new validator later → just add one map entry, no template changes needed elsewhere.
  • Often wrapped as a reusable pipe ({{ control | errorMessage }}) or a small dumb <app-field-error [control]="..."> component, so no template repeats the @if chain per field.
  • .touched/.dirty gate when errors are shown (don't show validation errors before the user's interacted with the field); markAllAsTouched() forces all errors to show on submit-without-touching-everything.

Pipes vs Directives

Pipe Directive
Syntax {{ value \| name }} Attribute or structural syntax on an element
Touches DOM? No — pure value transform Yes — attribute directives modify host element; structural add/remove DOM
Side effects? Should have none (pure by default) Routine — event listeners, DOM manipulation
  • Pipes: implement PipeTransform.transform(value, ...args). Chainable ({{ x | uppercase | slice:0:10 }}). Pure by default — only re-run if the input reference changes (same reference-equality rule as OnPush); { pure: false } re-runs every CD cycle, rare/expensive. Built-ins: date, currency, uppercase, json, async (auto subscribe/unsubscribe in-template, same spirit as toSignal).
  • Directives — three kinds:
  • Component — technically a directive with a template attached.
  • Attribute directive — modifies the host element it's attached to, no template of its own (ngClass, ngStyle, a custom [appHighlight]).
  • Structural directive — adds/removes entire DOM subtrees based on a condition. Modern @if/@for/@switch control-flow syntax replaces the older *ngIf/*ngFor (still valid, common in older code) — *ngIf desugars to an <ng-template> only instantiated when true; this is real DOM add/remove, not display:none. @for's track (old: trackBy) tells Angular which nodes to reuse vs destroy/recreate on list changes.

JS/TS High-Yield Sweep

  • == vs === — always use ===/!==; == coerces types inconsistently (0 == '0' → true, null == undefined → true but null === undefined → false). NaN === NaN is false — use Number.isNaN().
  • Falsy values, the complete list: false, 0, '', null, undefined, NaN. Everything else truthy — including '0' and [] and {}.
  • var/let/constvar is function-scoped and hoisted, avoid. let/const are block-scoped. const locks the binding/reference, not the contentsconst arr = []; arr.push(1) is fine; arr = [] is not.
  • Spread[...arr, x], {...obj, k: v} — expands into individual elements/properties, creates a new reference (directly relevant to the OnPush immutable-update pattern: items.update(current => [...current, newItem])).
  • Restfunction f(...args) — gathers remaining arguments/properties into one variable; opposite direction from spread.
  • Destructuringconst { a, b } = obj, const [x, y] = arr, including in function parameters.
  • Optional chaining ?. — short-circuits to undefined instead of throwing if a link in the chain is null/undefined.
  • Nullish coalescing ?? vs ||?? only falls back on null/undefined; || falls back on any falsy value including a legitimate 0, '', false. Classic bug: order.total || 0 incorrectly overrides a real total of 0; order.total ?? 0 doesn't. High-yield gotcha — have this cold.
  • map/filter/reduce — none mutate the original array, all return something new.
  • map — same length output, transforms each element.
  • filter — predicate keeps/drops elements, output length 0..n.
  • reduce(fn, initialValue) — accumulator carried through every element; fn returns the next accumulator value; final return is reduce's result. Accumulator can be any shape (number, object, array) — e.g. building a lookup object or grouping into buckets, not just summing.
  • Chain pattern: .filter(...).map(...).reduce(...) ≈ LINQ's .Where().Select().Sum().
  • this binding — arrow functions capture this lexically from the enclosing scope at definition time; regular function expressions get this determined by how they're called. This is why callbacks (.subscribe(), .then(), event handlers) are almost always arrow functions in Angular code — you want this to reliably mean the component instance.
  • Promise vs Observable — Promise: one eventual value, starts executing immediately on creation, no native cancellation, can't "resubscribe" and rerun. Observable: can emit multiple values over time, lazy (nothing runs until .subscribe()), natively cancellable (unsubscribe()). This is the actual root reason switchMap's cancellation trick has no clean native equivalent with plain async/await.
  • Generators (function*, yield) — JS/TS parallel to IEnumerable deferred execution; function body doesn't run until a value is pulled via .next(). Rarely asked directly but conceptually the same laziness Observables share.

Promise Syntax in Angular

  • async/await works the same as any TS/JS — nothing Angular-specific about the syntax.
  • firstValueFrom(observable$) — Observable → Promise, resolves on the first emission, auto-unsubscribes. Fine for genuinely one-shot triggers (bootstrap config, inside a guard/resolver). Wrong tool if a user can re-trigger the same action before the first resolves — no cancellation, last-response-wins race.
  • lastValueFrom(observable$) — same bridge, but waits for the last emission before the source completes; matters if the Observable emits more than once (firstValueFrom would resolve early and miss the rest).
  • Native fetch() bypasses HttpClient entirely — works, but loses Angular's interceptor pipeline (auth headers, centralized error handling). Real code sticks to HttpClient + Observables, converting to a Promise only at the edge.
  • Don't mix try/catch (Promise style) and catchError (RxJS style) in the same function — pick one per function.
async loadOrder(id: string) {
  try {
    const order = await firstValueFrom(this.orderApi.getOrder(id));
    this.result.set(order);
  } catch {
    this.error.set('Order not found');
  }
}

Timer Syntax — timer / interval (RxJS, not plain JS)

timer(3000).subscribe(() => {});        // emits ONCE after 3s, then completes
timer(0, 5000).subscribe(() => {});     // first emission immediately, then every 5s, forever
interval(5000).subscribe(() => {});     // every 5s, starting at 5s — same as timer(5000, 5000)
  • The plain setInterval bug it solves: setInterval doesn't wait for async work inside it to finish — if the callback's async call takes longer than the interval period, calls overlap and stack up (the exact race your background sync engine needs to avoid).
  • The fixinterval() piped through exhaustMap (not switchMap): don't cancel an in-flight sync (could leave data half-synced), skip the next tick entirely if one's still running.
interval(5000).pipe(
  exhaustMap(() => this.syncQueue.flush()),
  takeUntilDestroyed(this.destroyRef)
).subscribe();
  • takeUntilDestroyed() matters more here than almost anywhere — interval() never completes on its own, so without cleanup it runs forever even after the component is gone.

RxJS Operator Recap

Core framing: every operator answers "when a new value arrives, what do I do with it?" Plain value out → map. Observable out (another async call) → one of the four flattening operators.

Operator On new value while previous in-flight Use when
switchMap Cancel previous, start new Only latest result matters (search box)
concatMap Queue — wait, then run next Order matters (sequential uploads)
exhaustMap Ignore the new value Prevent duplicate action while one's running (payment button, sync timer tick)
mergeMap Run both in parallel Order doesn't matter, want speed (N profiles at once)
// switchMap — search
searchControl.valueChanges.pipe(
  debounceTime(300),
  switchMap(val => this.http.get(`/search?q=${val}`))
).subscribe(results => this.results = results);

// concatMap — ordered uploads
fileUploads$.pipe(concatMap(file => this.uploadService.upload(file)))
  .subscribe(result => console.log('uploaded:', result));

// exhaustMap — payment button / sync timer
paymentClicks$.pipe(exhaustMap(() => this.paymentService.submit()))
  .subscribe(result => console.log(result));

// mergeMap — parallel, order doesn't matter
userIds$.pipe(mergeMap(id => this.http.get(`/users/${id}`)))
  .subscribe(user => console.log(user));
  • forkJoin — not a pipe operator, a standalone combinator. Fires all given Observables simultaneously, emits once, only after all complete, as an array/tuple in the same order passed in. If any one errors or never completes, forkJoin never emits at all.
forkJoin([
  this.http.get('/user/profile'),
  this.http.get('/user/settings'),
  this.http.get('/user/permissions')
]).subscribe(([profile, settings, permissions]) => { ... });

package-lock.json / node_modules / npm ci

  • package.json — loose version ranges (^7.8.0).
  • package-lock.json — exact resolved versions + integrity hashes for the whole dependency tree. Committed to git. Nearest .NET equivalent: packages.lock.json.
  • node_modules/ — actual downloaded files. Not committed (gitignored).
  • npm install — can update the lock file if it's out of sync with package.json.
  • npm ci (used in CI/CD) — requires lock file and package.json to agree exactly; deletes node_modules fresh; installs exactly what the lock file says, nothing more. The one to name for "how do you ensure reproducible builds."