# TypeScript `StrictPropertyInitialization` in Real Codebases: Patterns That Pass the Compiler Without Lying

> Source: <https://dev.to/jsmanifest/typescript-strictpropertyinitialization-in-real-codebases-patterns-that-pass-the-compiler-2e85>
> Published: 2026-10-21 06:06:52+00:00

*This article was written with the assistance of AI, under human supervision and review.*

TypeScript's `strictPropertyInitialization` flag catches a specific class of bug: class properties that the compiler believes are defined but remain undefined at runtime. The flag forces developers to prove that every property without an explicit undefined type gets a value during construction. Most codebases respond by sprinkling definite assignment assertions (`!`) across property declarations, which satisfies the compiler but reintroduces the exact risk the flag exists to prevent.

The gap appears because developers treat `strictPropertyInitialization` as a compiler nuisance rather than a design constraint. When a property cannot be initialized in the constructor, the correct response is rarely "tell the compiler to trust you". Five patterns exist that satisfy the flag honestly: definite assignment only when initialization is guaranteed, private initialization methods, optional properties that match runtime behavior, dependency injection with nullable types, and abstract class contracts that defer initialization to derived classes. Each pattern preserves the type safety the flag provides while accommodating real initialization requirements.

`strictPropertyInitialization` prevents runtime undefined access by forcing explicit initialization contracts in class constructors.`!`) are safe only when third-party decorators or framework lifecycle hooks guarantee initialization before property access.
The flag performs a reachability analysis on class constructors. For every property declared without `?` or `| undefined` in its type, TypeScript traces all constructor code paths to verify that the property receives a value before the constructor completes. If any path reaches the end without an assignment, the compiler reports an error.

The analysis catches three specific failures. First, conditional initialization where some branches skip assignment. Second, initialization in methods called after construction. Third, properties that depend on external state not available during construction. All three scenarios produce runtime undefined where the type system claims a value exists.

```
class DataLoader {
  // Error: Property 'data' has no initializer
  data: string[];

  constructor(private shouldLoad: boolean) {
    if (shouldLoad) {
      this.data = [];
    }
    // If shouldLoad is false, data stays undefined
  }
}
```

The compiler does not care about the logic that determines whether `shouldLoad` is true. It sees that a code path exists where `data` remains unassigned and reports the violation. This is correct behavior: the type `string[]` promises that `data` holds an array, but the constructor breaks that promise in the false branch.

Developers encounter this most often in classes that perform asynchronous initialization. A common pattern loads configuration or fetches remote data in a method called after construction. The constructor cannot await these operations without blocking, so properties stay undefined until the async method completes.

```
class APIClient {
  // Error: Property 'token' has no initializer
  token: string;

  constructor(private endpoint: string) {}

  async initialize() {
    const response = await fetch(`${this.endpoint}/auth`);
    this.token = await response.text();
  }
}
```

The constructor completes before `initialize` runs, leaving `token` undefined. Any code that accesses `this.token` between construction and initialization sees undefined, contradicting the type. The flag surfaces this mismatch before production.

*TypeScript compiler analyzing class property initialization paths*

The definite assignment assertion (`!`) tells the compiler "this property will have a value before any code accesses it, guaranteed by external mechanisms you cannot see". The assertion is honest in exactly one scenario: when a third-party system (decorators, framework lifecycle hooks, dependency injection containers) initializes the property before user code runs.

Frontend frameworks demonstrate the legitimate use case. Angular's `@Input()` decorator guarantees that the framework sets the property during component initialization, before lifecycle hooks or templates run. The developer never writes code that assigns the property, but the framework contract ensures initialization occurs.

``` js
import { Component, Input } from '@angular/core';

@Component({
  selector: 'user-card',
  template: '<div>{{ user.name }}</div>'
})
export class UserCardComponent {
  // Framework guarantees initialization before ngOnInit
  @Input() user!: { name: string; email: string };

  ngOnInit() {
    // user is always defined here
    console.log(this.user.email);
  }
}
```

The assertion holds because Angular's component initialization sequence sets all `@Input()` properties before calling `ngOnInit()`. The type system cannot see this external guarantee, so the assertion documents it. Removing the assertion would force the property to be `user?: { name: string; email: string }`, which misrepresents the runtime state after initialization.

The pattern fails when developers use `!` to silence errors without an external guarantee. A common mistake applies the assertion to properties initialized in async methods, where no framework ensures the async method runs before property access.

```
class ConfigLoader {
  // Lies to the compiler
  config!: { apiKey: string };

  async loadConfig() {
    const response = await fetch('/config.json');
    this.config = await response.json();
  }
}

const loader = new ConfigLoader();
console.log(loader.config.apiKey); // undefined.apiKey crashes
```

The assertion claims `config` is always defined, but nothing prevents code from accessing `config` before `loadConfig()` completes. The correct solution uses an optional property or undefined union to match the actual runtime state.

When initialization logic grows complex, extracting it to a private method keeps the constructor readable. The challenge is that `strictPropertyInitialization` requires direct assignment in the constructor body. Calling a private method that performs the assignment satisfies the flag as long as the method is called synchronously in the constructor.

```
class DataProcessor {
  private readonly config: ProcessorConfig;
  private readonly validators: Array<(data: unknown) => boolean>;

  constructor(options: ProcessorOptions) {
    this.config = this.buildConfig(options);
    this.validators = this.buildValidators(this.config);
  }

  private buildConfig(options: ProcessorOptions): ProcessorConfig {
    return {
      maxRetries: options.maxRetries ?? 3,
      timeout: options.timeout ?? 5000,
      enableCache: options.enableCache ?? true
    };
  }

  private buildValidators(config: ProcessorConfig): Array<(data: unknown) => boolean> {
    const validators = [this.validateSchema];
    if (config.enableCache) {
      validators.push(this.validateCacheKey);
    }
    return validators;
  }

  private validateSchema(data: unknown): boolean {
    return typeof data === 'object' && data !== null;
  }

  private validateCacheKey(data: unknown): boolean {
    return 'cacheKey' in (data as object);
  }
}
```

This pattern preserves type safety by keeping initialization synchronous. The compiler sees that `buildConfig` returns a value, that value is assigned to `this.config`, and the assignment happens before the constructor exits. The private method is an organizational tool, not an escape hatch.

The pattern breaks when developers move assignments into async private methods. The compiler only analyzes the constructor body; it cannot see that an async method will eventually assign a value. This produces the same mismatch as calling async methods after construction.

```
class AsyncDataProcessor {
  // Error: Property 'config' has no initializer
  private readonly config: ProcessorConfig;

  constructor(options: ProcessorOptions) {
    // This doesn't count as initialization
    this.loadConfig(options);
  }

  private async loadConfig(options: ProcessorOptions) {
    const response = await fetch('/config');
    this.config = await response.json();
  }
}
```

The constructor calls `loadConfig`, but the method returns a Promise that resolves later. The constructor exits before the assignment happens, leaving `config` undefined. The correct approach uses an optional property or a factory function that awaits initialization before returning the instance.

When a property cannot be initialized in the constructor and no external system guarantees initialization, the type must reflect that uncertainty. Two syntactic forms express this: optional properties (`property?: Type`) and undefined union types (` property: Type | undefined`). They differ in subtle but important ways.

Optional properties indicate that the property key may not exist on the object. The `in` operator returns false, `Object.keys()` omits the property, and JSON serialization excludes it. This matches patterns where the property is added conditionally or after some initialization step.

```
class FeatureFlag {
  name: string;
  enabled?: boolean;

  constructor(name: string) {
    this.name = name;
    // enabled is not set
  }

  activate() {
    this.enabled = true;
  }
}

const flag = new FeatureFlag('newUI');
console.log('enabled' in flag); // false
flag.activate();
console.log('enabled' in flag); // true
```

Undefined union types indicate that the property key always exists but the value may be undefined. The `in` operator returns true, `Object.keys()` includes the property, and JSON serialization writes `null`. This matches patterns where the property is explicitly set to undefined as a placeholder.

```
class CacheEntry {
  key: string;
  value: unknown | undefined;
  expiresAt: number | undefined;

  constructor(key: string) {
    this.key = key;
    this.value = undefined;
    this.expiresAt = undefined;
  }

  set(value: unknown, ttl: number) {
    this.value = value;
    this.expiresAt = Date.now() + ttl;
  }
}

const entry = new CacheEntry('user:123');
console.log('value' in entry); // true
console.log(entry.value); // undefined
```

The distinction matters for serialization and runtime checks. Optional properties disappear from JSON when unset, which can break APIs expecting consistent object shapes. Undefined unions preserve the key but serialize as `null`, maintaining shape while signaling absence.

Choosing between them depends on the domain model. Optional properties fit entities where presence indicates state ("this user has a verified email address"). Undefined unions fit value containers where the slot always exists ("this cache entry has no value yet"). Both satisfy `strictPropertyInitialization` by making the possibility of undefined explicit in the type.

Dependency injection frameworks often construct objects in two phases: instantiation and injection. The constructor runs first with no dependencies, then the framework injects dependencies through property assignment or setter methods. This pattern conflicts with `strictPropertyInitialization` because dependencies cannot be initialized in the constructor.

The solution pairs nullable property types with initialization guards that verify dependencies before use. The property type includes `undefined`, documenting that the property starts uninitialized. An initialization method checks that all dependencies are set before the object becomes operational.

```
interface Logger {
  log(message: string): void;
}

interface MetricsClient {
  increment(metric: string): void;
}

class ServiceHandler {
  // Dependencies injected after construction
  logger: Logger | undefined;
  metrics: MetricsClient | undefined;

  private initialized = false;

  // Framework calls this after injecting dependencies
  initialize() {
    if (!this.logger || !this.metrics) {
      throw new Error('ServiceHandler: dependencies not injected');
    }
    this.initialized = true;
  }

  handleRequest(request: unknown) {
    if (!this.initialized) {
      throw new Error('ServiceHandler not initialized');
    }
    // Now we know logger and metrics are defined
    this.logger.log('Handling request');
    this.metrics.increment('requests.handled');
  }
}
```

This pattern trades compile-time guarantees for runtime checks. The type system cannot verify that the framework calls `initialize()` before `handleRequest()`, so the code must perform that verification itself. The benefit is that the types document the actual state: dependencies are uncertain until initialization completes.

An alternative uses a factory function that performs injection and returns a fully initialized object. This moves the initialization logic outside the class, where it can await async dependencies and return a complete instance.

```
interface ServiceDependencies {
  logger: Logger;
  metrics: MetricsClient;
}

class ServiceHandler {
  // All properties initialized by factory
  constructor(
    private readonly logger: Logger,
    private readonly metrics: MetricsClient
  ) {}

  handleRequest(request: unknown) {
    this.logger.log('Handling request');
    this.metrics.increment('requests.handled');
  }
}

async function createServiceHandler(): Promise<ServiceHandler> {
  const logger = await createLogger();
  const metrics = await createMetrics();
  return new ServiceHandler(logger, metrics);
}
```

The factory pattern satisfies `strictPropertyInitialization` by moving uncertainty out of the class. The class constructor requires all dependencies as parameters, so properties are initialized synchronously. The factory handles the complexity of obtaining dependencies, and callers receive a fully operational object.

*Dependency injection initialization sequence with type guards*

Abstract classes define contracts that derived classes must fulfill. When a base class declares a property without initializing it, derived classes can provide the initialization in their constructors. TypeScript allows this as long as the property has an abstract declaration or an undefined union type.

Abstract properties force derived classes to provide a value. The base class cannot reference the property in its constructor because the value does not exist yet, but methods defined in the base class can access the property knowing that derived constructors have initialized it.

```
abstract class DataSource {
  abstract readonly connectionString: string;

  async connect(): Promise<void> {
    // connectionString is guaranteed to be set by derived class
    console.log(`Connecting to ${this.connectionString}`);
  }
}

class PostgresDataSource extends DataSource {
  readonly connectionString: string;

  constructor(host: string, database: string) {
    super();
    this.connectionString = `postgresql://${host}/${database}`;
  }
}

class MongoDataSource extends DataSource {
  readonly connectionString: string;

  constructor(uri: string) {
    super();
    this.connectionString = uri;
  }
}
```

This pattern works because the base class delegates initialization responsibility to derived classes. The abstract property declaration communicates that the base class requires the property but does not provide it. Derived classes must supply a value in their constructors to satisfy the contract.

The pattern fails when developers use base class constructors that reference properties before derived constructors run. JavaScript's constructor execution order calls the base constructor first, then the derived constructor. Any property access in the base constructor sees undefined values.

```
abstract class LoggingDataSource {
  abstract readonly connectionString: string;

  constructor() {
    // WRONG: derived constructor hasn't run yet
    console.log(`Will connect to ${this.connectionString}`);
  }
}

class FileDataSource extends LoggingDataSource {
  readonly connectionString: string;

  constructor(path: string) {
    super(); // Base constructor runs before this line
    this.connectionString = path;
  }
}
```

The base constructor logs `undefined` because `connectionString` does not get a value until after `super()` returns. The correct approach avoids property access in base constructors, deferring it to methods that run after the derived constructor completes.

For more complex type manipulation patterns that complement strict initialization, see [Partial, Required, and DeepPartial in TypeScript](https://jsmanifest.com/partial-required-deeppartial-typescript-2026) and [Generic Constraints with extends and keyof](https://jsmanifest.com/typescript-generic-constraints-extends-keyof).

Disabling `strictPropertyInitialization` is a deliberate tradeoff: developer convenience in exchange for runtime risk. Three scenarios justify the tradeoff, but each carries a specific cost that must be understood and accepted.

First, brownfield codebases with thousands of class properties where refactoring to satisfy the flag would take weeks. The cost is accepting that a subset of properties may be accessed before initialization, and those bugs will surface as production crashes instead of compile errors.

Second, codebases that use dependency injection frameworks where nearly every class has injected properties. The cost is that types lie about property availability during the object's lifetime, and developers must remember which properties are safe to access when.

Third, prototyping and experimental code where type safety is less important than iteration speed. The cost is that the prototype's initialization bugs will need fixing before production, and disabling the flag defers that work to later.

The flag should never be disabled simply because errors are annoying or because patterns exist that cannot satisfy it. Every suppressed error is a potential undefined access that reaches production. If a pattern cannot satisfy the flag, the pattern does not match TypeScript's type system guarantees and should be redesigned.

Two mitigation strategies reduce the risk when the flag must be disabled. First, enable `strictNullChecks` to catch undefined access at the point of use. This does not prevent initialization bugs, but it forces explicit undefined checks that surface problems earlier.

```
// With strictNullChecks, this requires a guard
class APIClient {
  token: string; // No initializer, flag disabled

  makeRequest() {
    // Error: Object is possibly undefined
    const headers = { Authorization: `Bearer ${this.token}` };
  }

  makeRequestSafe() {
    if (!this.token) {
      throw new Error('Client not initialized');
    }
    const headers = { Authorization: `Bearer ${this.token}` };
  }
}
```

Second, establish a team convention that properties without initializers must be optional or union with undefined. This documents uncertainty in the type even when the compiler does not enforce initialization. The convention requires discipline but prevents the most common mistake: claiming a property is always defined when it is not.

For validation patterns that pair with strict initialization to ensure runtime safety, see [TypeScript Form Validators and Custom Validation](https://jsmanifest.com/typescript-form-validators-custom).

Only when a third-party system (framework lifecycle, decorator, DI container) guarantees property initialization before user code runs. If you control the initialization code, use constructor assignment or optional properties instead.

Optional properties (`prop?: Type`) indicate the key may not exist on the object, affecting serialization and `in` operator checks. Undefined unions (`prop: Type | undefined`) keep the key present with an undefined value, preserving object shape.

Yes. Declare the property as abstract, and derived classes must provide initialization in their constructors. The base class cannot access the property in its constructor but can reference it in methods.

No. It is independent of `strictNullChecks`, `strictFunctionTypes`, and other strict checks. However, keeping `strictNullChecks` enabled when disabling `strictPropertyInitialization` provides some protection by requiring undefined checks at access points.

By declaring injected properties with undefined union types (`prop: Type | undefined`) and using initialization guards before property access. Alternatively, use a factory function that injects dependencies and returns a fully initialized instance.

The flag's value is not in catching every uninitialized property but in forcing developers to be explicit about initialization contracts. When a property cannot be initialized in the constructor, that uncertainty must appear in the type. Optional properties, undefined unions, and nullable dependencies document the actual state of the object through its lifetime.

Definite assignment assertions work when external systems guarantee initialization, but only then. Constructor initialization through private methods works when the methods are synchronous. Dependency injection works when properties admit they are nullable until injection completes. Abstract classes work when base constructors avoid accessing derived properties.

Each pattern trades off convenience for correctness in different ways. The compiler cannot verify that an async method runs before property access, so the code must guard access with runtime checks. The compiler cannot see decorator behavior, so the developer must document the guarantee with an assertion. The compiler cannot trace factory function initialization, so the factory must return a complete object.

The patterns share a principle: the type should match the runtime state at every point in the object's lifetime. A property that starts undefined and becomes defined through initialization should have a type that reflects that transition. A property that is defined when an external system says so should document that contract. Keeping types and runtime in sync makes `strictPropertyInitialization` work without lying to the compiler.
