November 15, 2022·6 min read·engineeringtypescriptsatisfiesnarrowing

TypeScript 4.9 separates compatibility checks from exposed types

TypeScript 4.9 adds satisfies for source-authored values and improves in-operator narrowing for unknown objects. Both let the checker follow evidence without requiring a broad annotation or an unsupported assertion.

A shared type describes what an object is allowed to provide. The object's initializer often says more, including which member is a string and which is a tuple. TypeScript 4.9 gives authors a way to check the shared requirement without replacing all that local information with the broader contract.

The November 15 release announcement makes satisfies a stable language feature. It also improves the in operator for checking properties that no existing type declaration lists. These changes address different sources of knowledge, with source-authored configuration in one case and runtime inspection of unknown values in the other.

I value TypeScript when it makes the relationship between an implementation and its contract easier to inspect. Both features reduce pressure to use an assertion where the program already has evidence. Their limits matter just as much, because neither turns a static declaration into automatic validation of arbitrary input.

A broad annotation can discard useful distinctions

The release announcement uses a palette whose values may be strings or RGB tuples. Annotating the whole object with a record of that union checks the required keys and permitted values. Consumers then see the broad annotated value type when they access a property.

That is a reasonable contract for a value whose implementation should remain hidden. It is less convenient when the program wants to use the initializer's known distinctions directly. Calling a string-specific method on the string member should not require recovering information that the declaration just discarded.

The new operator checks assignability while keeping the operand's resulting type. A fresh object literal can be checked for required names and compatible values while its consumers retain the member information established by the initializer.

type Color = "red" | "green" | "blue"
type RGB = [number, number, number]

const palette = {
    red: [255, 0, 0],
    green: "#00ff00",
    blue: [0, 0, 255]
} satisfies Record<Color, string | RGB>

const uppercaseGreen = palette.green.toUpperCase()

This example uses a string-only method to demonstrate the distinction. The contract allows other value forms elsewhere, but the green member's expression supplies a string. The checker can retain that information without relaxing the requirement that every property satisfies the record.

A misspelled color in this fresh declaration receives a diagnostic. A tuple with the wrong length also fails the stated value contract. The operator is useful because the check occurs where the source value is authored, while the expression remains useful to later consumers.

The target still provides context

The 4.9.3 checker implementation shows the operation directly. It checks the target type, checks the expression, verifies assignability and returns the expression's type. A separate contextual-type case returns the target type for the operand of a satisfaction expression.

Those two facts need to be understood together. The result does not simply become the target type, but the target can influence the expression's inference. Describing the feature as having no effect on inference would therefore be too strong.

Context matters for object literals and callbacks. A target can provide the expected parameter type for a callback, avoiding an otherwise unsupported implicit parameter type. Literal alternatives can also affect how narrowly the initializer is represented. The resulting type is the expression's checked type in that context.

This is why the operator should be chosen for its contract, rather than as a universal instruction to freeze inference. It checks compatibility while retaining an operand type. It does not promise that every object property becomes a literal or that every property remains freely assignable to all values accepted by the target.

The distinction is visible in the implementation's order. It obtains the expression type and checks it against the target, then returns that expression type to the surrounding analysis. The target supplies a requirement without becoming the entire public description of the value by default.

Key policy belongs in the target type

A finite key union and a string index signature express different requirements. The first describes a known vocabulary, with the palette example requiring all named colors. The second allows arbitrary names while constraining each associated value.

Using Record<string, string | RGB> can check a collection of color values whose names are open-ended. It cannot discover that a particular domain intended exactly three predefined names. The compiler needs that domain vocabulary in the target type before it can identify an unexpected name.

Conversely, a finite record can be too restrictive for an extensible registry. The operator does not decide whether extensibility is valid. It applies the contract the author selects, so the target should reflect what consumers actually require.

I prefer a finite vocabulary for configuration that must cover an application's known cases. That puts omissions and misspellings near the declaration. For an open registry, retaining the actual inferred keys can still be useful, but it should coexist with a target that intentionally permits extension.

This also limits claims about exactness. The release demonstrates diagnostics on source-authored object literals. That does not establish a universal runtime exact-object validator for every previously existing value. Compatibility remains a static relation applied under TypeScript's normal expression rules.

Unknown input needs executable evidence

A configuration read from outside the program has a different starting point. The checker cannot inspect the future data in the way it inspects a source initializer. A safe unknown value therefore needs runtime checks before the program accesses a property.

TypeScript 4.9 improves a common sequence. After checking that the value is a non-null object, a property-presence test can establish access to a property that no declared member type listed beforehand. The checker intersects the object type with a record whose checked key maps to unknown.

function readName(value: unknown): string | undefined {
    if (value !== null && typeof value === "object") {
        if ("name" in value && typeof value.name === "string") {
            return value.name
        }
    }
    return undefined
}

The first condition establishes an object suitable for the property test. The in check supplies evidence that the property exists. Its value is still unknown until the final string check establishes the operation the return type needs.

Earlier analysis could narrow unknown to object but still refuse the property access because object did not declare that name. The new intersection gives the checker a representation for the observed property without pretending the complete object shape is known.

That representation is intentionally limited. Finding one property does not validate every other field or establish a complete application schema. It gives the following code a value it can inspect independently. Further required properties need their own evidence.

The release also tightens the operands accepted by in. The left side must be compatible with a property key and the right side with object. This checks the validity of the inspection operation itself, alongside improving what a successful check can establish.

Static compatibility and runtime inspection complement each other

The source-authored palette and externally supplied name use different validation mechanisms. A source initializer can be checked during compilation because the initializer is present in the source. The latter requires the executable conditions to inspect the value that actually arrives.

A type assertion at either boundary would ask the checker to accept the author's claim. The new features make that unnecessary in these examples because each has an identifiable source of evidence. Compatibility analysis reads the initializer, while control-flow analysis follows runtime checks.

The distinction also affects failure behavior. An incompatible palette declaration produces a compiler diagnostic. The name-reading function handles unsuitable runtime values by returning undefined. A library designing both interfaces should choose those failure contracts deliberately instead of treating their type syntax as equivalent validation.

No generated schema is implied by satisfies. The compiler checks a relationship and removes the type-level construct during transformation. A program requiring a runtime schema must still execute one or perform the necessary explicit tests.

The release also changes the cost of staying in watch mode

TypeScript 4.9 moves its default file-watching strategy toward filesystem events, falling back to polling when event-based watchers cannot be established. The announcement explains that polling repeatedly checks state even when files have not changed, which can become expensive across large projects.

Event-based watching has platform and filesystem constraints, including inode replacement and networked filesystems. The release retains configuration through environment settings and watch options. The default changes because the implementation has accumulated support for those cases, not because every filesystem suddenly provides identical behavior.

For application development, this is separate from the new type relation but part of the same tool's daily behavior. Configuration declarations receive compatibility checks, runtime guards receive better narrowing, and the editor can monitor source changes with less repeated polling. Each improvement has a specific mechanism and a stated scope, which makes its benefit easier to assess in the project that uses it.