May 31, 2018·6 min read·engineeringtypescripttypesmodules

TypeScript 2.9 makes property-key types reflect more of JavaScript

Numeric and symbolic properties now participate in keyof and mapped types. Import types separately let declarations refer to module types without changing a global script into a module.

A generic function accepting any property key should not assume that the key is a string. TypeScript's earlier keyof behaviour encouraged that assumption because its key queries omitted numeric and symbolic property names. TypeScript 2.9 includes those names, making generic object operations account for more of the values they can actually receive.

The May 31 announcement connects this change with the unique-symbol support introduced earlier in the year. The compiler could already understand an individual symbol-keyed property, but a mapped transformation could lose it. Extending key queries lets reusable transformations preserve that part of the object's declaration.

I am divided about TypeScript, while finding its ecosystem valuable for building web interfaces carefully. This is the kind of consistency change that helps. A generic helper should describe the properties its runtime operation actually handles, rather than imposing a string-only assumption that the source object never made.

A key query can include numbers and symbols

The 2.9 release notes define a key query as a subtype of string | number | symbol. For an object with specifically declared properties, the result includes the appropriate literal types and unique-symbol types.

const marker = Symbol("marker");

interface Reading {
    name: string;
    0: number;
    [marker]: boolean;
}

type ReadingKey = keyof Reading;

Here the query includes the string literal name, the numeric literal zero and the type of marker. A generic accessor can accept those keys and use an indexed-access type to preserve the relationship between the selected key and its result.

function read<T, K extends keyof T>(value: T, key: K): T[K] {
    return value[key];
}

The declaration does not need a separate overload for every kind of key. Its parameter constraint says that the selected key must belong to the particular object's key set. The return type then queries that same object's value type at the selected key.

This matters for protocol-oriented libraries using exported symbols. Such a property may be meaningful to the library even if ordinary string-based enumeration does not list it. A key query that drops the symbol cannot faithfully describe a transformation over the declared object structure.

The change also handles index signatures. The release notes explain that a numeric index signature contributes number to the key type. A string index signature contributes both string and number, together with separately known symbol-like properties. These are rules for the type query, not a claim that every runtime enumeration returns values of every included kind.

Mapped types retain the newly recognized keys

Mapped types operate over key sets, so the richer query affects utilities such as Partial<T> and Readonly<T>. A transformation over keyof Reading can now include the numeric and symbolic members along with name. The declaration does not silently omit the symbol merely because the utility is generic.

The release notes give another example using an array-like object with a numeric index signature and a length property. Applying Readonly previously lost useful information about numeric indexing. With the updated key support, indexed reads retain the element type instead of degrading into an untyped result or an implicit-any error.

The benefit is compositional. A library can describe an object once and apply a standard transformation while preserving its declared key categories. Without that preservation, authors need special-purpose declarations for shapes that should work with an ordinary mapped type.

These transformations still describe types. Applying Readonly<T> to a declaration does not execute code that freezes an object, just as computing keyof T does not enumerate a runtime value. The compiler's improved model lets the declared relationship remain accurate without changing the JavaScript operation.

String-only code needs a string-only constraint

This broader result is a breaking change for generic code assigning a keyof T value to a string. The compiler now points out that the generic key could be numeric or symbolic. Whether to widen the receiving type depends on the function's real operation.

The announcement recommends Extract<keyof T, string> when a helper intentionally supports string-named properties only. That constraint states a smaller contract. A helper that passes its key to a string-specific operation no longer needs to pretend it accepts the object's entire key set.

For code that genuinely handles every key category, the receiving declaration can use string | number | symbol, or the built-in PropertyKey alias. This moves the correction to the place where the original assumption was too narrow. Merely asserting that an arbitrary key is a string would leave the mismatch in place.

TypeScript 2.9 also changes the inferred variable in a generic for...in loop to Extract<keyof T, string>. This is an important distinction between querying a declared structure and iterating its string-like property names. Broadening the former does not require assigning the latter a type that includes symbols.

The keyofStringsOnly flag offers transitional old behaviour. Its existence lets a project sequence adoption, but it is not evidence that the old assumption accurately describes every object. The underlying interface question remains whether the function handles string keys specifically or any supported key.

My preference is to make that choice explicit at the helper boundary. It makes the declaration informative to callers and prevents a utility's internal string operation from silently constraining an interface advertised as generic over all properties.

Import types preserve a script's existing scope

Another change in this release addresses a different mismatch between type information and program structure. Before 2.9, referring to a type exported by another module generally required a top-level import. Adding that import to a global script made the file a module, changing its scoping rules.

The new import-type syntax allows a type annotation to refer directly to an exported declaration.

function describe(value: import("./model").Reading): string {
    return value.name;
}

This example assumes ./model exports the Reading interface. The annotation identifies the module type without introducing a top-level import declaration into this script. TypeScript can therefore describe the parameter while preserving the file's global-script role.

The similar spelling to a dynamic import expression deserves attention. In the example it occurs in a type annotation. It describes a type and does not request that the function dynamically load the module when it runs. A dynamic import() expression occupies a value position and performs a different job.

This is useful for JavaScript checking too. The release notes show import types inside JSDoc annotations, letting a JavaScript file refer to another module's declarations. The ability to describe the relationship no longer depends on converting the file's runtime module arrangement just to satisfy an annotation.

Declaration generation can name inferred module types

Import types also help the compiler generate declarations. The notes show an exported value inferred from a module function whose result type belongs to that module. Previously declaration emission could complain that the external type could not be named in the output.

TypeScript 2.9 can express the generated annotation through an import type, such as import("crypto").Hash. That gives the emitted declaration a way to identify the type without requiring the original source to add a redundant named import purely for declaration generation.

The mechanism makes inferred source and published declarations easier to keep aligned. A library author can let an implementation call determine a value's type, while the declaration emitter names that relationship in a form consumers understand. This does not remove the need to review a public declaration, but it removes a naming failure unrelated to the value's actual type.

JSON imports include a build-output responsibility

The release also introduces resolveJsonModule. With the supported module-resolution configuration, the compiler can read an imported JSON file and infer types from its contents. The announcement demonstrates boolean settings and rejects comparison of one with a number.

It also states that the JSON files carry into the output directory. That detail connects checking with execution. Recognizing a configuration file's shape at compile time is useful only if the generated application can still find the file its import expects. TypeScript 2.9 accounts for both the declaration inferred from the JSON and the artifact needed by the emitted program.

Generic invocation also becomes more consistent in JSX and tagged templates. The release notes allow explicit type arguments in those positions, matching a capability already useful in ordinary function calls. When inference has insufficient candidates, an author can supply the missing type argument without annotating every callback parameter with a larger combined type.

For a component library, that matters when the framework contributes some properties and the application supplies others. An explicit argument can identify the application properties while the library declaration combines them with its own. The change gives the call site a direct way to express the intended input to generic inference, preserving the library's existing relationship between its supplied and required properties.