August 26, 2021·7 min read·engineeringtypescriptoptional-propertiesnarrowingerrors

TypeScript 4.4 distinguishes omitted properties from undefined values

TypeScript 4.4 adds exact optional-property checking and preserves more control-flow relationships. The release also makes caught values unknown under strict checking, which changes how error handlers establish their assumptions.

Two objects can return the same value for a property read while behaving differently when copied or enumerated. An absent property and an explicitly stored undefined both yield undefined on an ordinary read, but only one object contains the key. TypeScript 4.4, released on August 26, adds an option that preserves this distinction in optional-property declarations. The change matters for APIs whose meaning depends on presence as well as value.

The release also preserves type guards through named conditions and changes the default type of caught values under strict checking. These are related improvements in the information available to the checker. The declarations can describe a more exact object, and control-flow analysis can retain facts established before a later operation uses that object.

I prefer declarations that make the application's real distinctions visible. TypeScript helps with web application code when its types describe what an option or a returned value actually means. An annotation that collapses omission into an explicit value can hide a decision that a configuration merge will still have to make at runtime.

Optional does not have to permit an explicit undefined value

Historically, TypeScript interpreted an optional property as though its declared value type also included undefined. An object with timeout?: number could therefore contain a timeout key whose value was undefined. That behavior accommodated code that treated the two cases interchangeably.

The new exactOptionalPropertyTypes option stops adding that value type automatically. An optional numeric property may be omitted, but supplying it requires a number unless the declaration explicitly includes another accepted type. The optional marker and the value union now answer different questions.

interface RequestOptions {
  timeout?: number
}

const inherited: RequestOptions = {}
const selected: RequestOptions = { timeout: 30 }

// Error with exactOptionalPropertyTypes enabled.
const ambiguous: RequestOptions = { timeout: undefined }

For a merge that starts with defaults and then copies supplied properties, the difference is concrete. An omitted key supplies nothing to overwrite a default. An explicitly present key can participate in the copy even when its value is undefined. The release announcement identifies object spread and Object.assign among the operations where presence matters.

This does not change the result of reading a missing property in JavaScript. A read still needs to account for absence. The option changes which assignments the declaration permits, so it cannot be understood simply as removing undefined from every expression involving the property.

If an API intentionally accepts both omission and an explicit undefined value, it can write timeout?: number | undefined. That longer declaration records both permissions. An API that gives those inputs different operational meanings should explain the distinction alongside the type.

Enumeration supplies a direct way to see the difference without involving a complex application. Object.keys({}) has no key to report, while Object.keys({ timeout: undefined }) includes timeout. Both objects can yield undefined when that property is read. A test that checks only the read result therefore cannot establish that construction preserved the intended shape. The assertion has to observe the aspect of the object that the consuming operation uses.

This is especially relevant when an object represents changes to another object. A consumer may interpret omission as leaving an existing value alone. A present key can instead request an update, even when the proposed value is undefined. Exact optional-property checking lets the declaration reject that second form when the interface only permits numeric updates. The runtime implementation still defines what happens when callers arrive through an unchecked boundary.

The option requires a separate adoption decision

exactOptionalPropertyTypes requires strictNullChecks, but the release does not include it in the strict family. A project must enable it explicitly. That policy is important because existing object construction code and third-party declarations may rely on the historical assignment behavior.

The release announcement anticipates friction and describes planned declaration updates. It does not promise that switching the option on leaves every dependency's types unchanged. An object used as a partial update may already encode undefined as an instruction, or it may use undefined merely as a convenient spelling for omission. The new diagnostic asks the project to resolve that ambiguity.

Rewriting every failing declaration to include undefined would preserve permissiveness, but would not answer which inputs the API means to accept. The useful distinction comes from relating the declaration to the operation that consumes the object. Copying, enumeration and direct reading can observe different aspects of its shape.

For a public library, that relationship also affects callers. Tightening the declaration can reject code that previously compiled even when the library implementation is unchanged. The option makes the mismatch visible, but deciding whether to change the caller or the declaration still requires understanding the intended contract.

Named conditions retain their type guards

A separate improvement removes a common reason for assertions. Earlier versions understood an inline typeof condition but could lose the same relationship when it moved into a constant. TypeScript 4.4 follows supported aliases of type guards and uses them in later branches.

function upper(value: unknown): string | undefined {
  const isText = typeof value === "string"
  if (isText) {
    return value.toUpperCase()
  }
  return undefined
}

The condition names a fact about value, and the branch can use that fact to permit a string operation. The release describes subjects including constants, readonly properties and unmodified parameters. Stability is part of the reasoning because a prior check does not establish the type of an arbitrarily changed value.

The analysis can follow compositions of constants, including alternatives that combine separate guards. Its work has a depth cutoff, so it does not attempt unlimited reasoning through arbitrary boolean expressions. This is a bounded extension of control-flow analysis for common source structures.

The stable release also demonstrates extracting a discriminant into a constant and narrowing the original object. A check of the extracted kind can establish which object variant is available. That is distinct from destructuring both a discriminant and payload and expecting the separate local variables to retain every relationship. The implementation discussion explicitly identifies the latter pattern as unsupported by that work.

I prefer a named condition when it expresses a concept reused by the surrounding code. Requiring the programmer to repeat an expression merely to retain narrowing makes the checker influence source organization for the wrong reason. This improvement supports the refactor while retaining limits around mutation and analysis depth.

Caught values need evidence before property access

JavaScript permits throwing values that are not Error instances. A catch variable historically had type any, so code could access message without establishing that the caught value had such a property. This let a familiar error-handling idiom conceal an unchecked assumption.

TypeScript 4.4 introduces useUnknownInCatchVariables, which makes the default type unknown. This option belongs to strict, so existing projects using strict checking can receive new diagnostics after upgrading. Unlike exact optional properties, it does not require a separate explicit opt-in under that configuration.

function messageFor(value: unknown): string {
  try {
    throw value
  } catch (error) {
    if (error instanceof Error) {
      return error.message
    }
    return "Unrecognized thrown value"
  }
}

The runtime check establishes the property access used by this function. A string thrown by another part of the program follows the other branch. The example chooses a simple fallback policy, while an application's error reporting can make a different decision about unsupported values.

An explicit : any annotation remains an escape from the stricter default. That restores permission to access arbitrary properties but does not establish the assumption at runtime. The compiler option exposes the choice between proving a usable shape and opting out of checking it.

The distinction is useful in error handlers because a failure while reporting another failure can obscure the original cause. A handler that assumes every thrown value has one class's interface makes a stronger claim than JavaScript provides. Using unknown makes that claim something the code must justify.

Extension keys can have a defined pattern

The release also adds symbol and template string pattern index signatures. A declaration can accept keys beginning with data- without accepting every arbitrary string key. This lets an object type support a defined extension convention while retaining excess-property checks for unrelated names in object literals.

That is useful alongside exact optional fields. Known settings can describe their own presence and value rules, while an extension region has a separate key pattern and value type. A broad string index signature would otherwise admit names that may simply be misspellings of known settings.

The same release supports unions of the allowed infinite key domains, treating them as multiple index signatures. A declaration can therefore cover string and symbol keys explicitly. These signatures describe accepted property access and assignment through the type system. They do not inspect an external object and certify that its keys follow the declared convention.

TypeScript 4.4 gives applications more precise ways to state these contracts. Exact optional properties records whether a present value is permitted, while guard aliases preserve evidence already established in control flow. Strict catch variables require error handlers to establish the shape they use, and patterned index signatures let extensibility retain a defined naming rule.