September 27, 2018·6 min read·engineeringtypescripttypestuples

TypeScript 3.1 preserves tuple structure through mapped types

Mapped types can transform tuple elements without converting array methods and length into ordinary mapped properties. The compiler also supports version-specific declaration paths through typesVersions.

A transformation that changes each tuple element should leave the result usable as a tuple. Mapping every property indiscriminately can instead transform its length and methods, producing a type whose numeric positions look right while its container operations no longer make sense. TypeScript 3.1 gives mapped types explicit handling for tuples and arrays.

The npm registry records publication of TypeScript 3.1.1 on September 27. The compiler source at that release tag shows the mechanism directly. For a structure-preserving mapped type, it recognizes array and tuple inputs and constructs corresponding array or tuple results with transformed element types.

I appreciate TypeScript when its declarations make a framework's actual JavaScript behaviour easier to understand. Mapping a tuple is a useful example. The runtime operation can preserve position and length, so the type system should have a way to preserve that information without requiring separate overloads for every possible tuple size.

The mapped declaration identifies a structural relationship

The 3.1 release notes describe the relevant form as a homomorphic mapped type. A common example maps each property of a type through an operation using the original property value type.

type Promised<T> = {
    [K in keyof T]: Promise<T[K]>;
};

type Coordinate = [number, number];
type PendingCoordinate = Promised<Coordinate>;

The resulting pending coordinate is a pair of promises for numbers. It retains tuple structure, so its length and container operations still have their tuple meaning. The declaration transforms the selected elements, not every named member into a promise independently.

That distinction matters because a tuple is also array-like. Its type includes operations and a length property in addition to its positional elements. If a mapped type converts all of those members as ordinary properties, it can replace callable methods with non-callable values and replace the length with something that no longer describes the container.

The new rule makes the operation correspond to the usual intention of mapping a list. Each element changes according to the template, while the result remains the same category of container. This is a compiler rule for the recognized mapped form, not a general theorem that every type containing numeric properties must be treated as a tuple.

The source's getHomomorphicTypeVariable() identifies a mapped constraint that is an index type over a type parameter. That gives the implementation a particular original type to examine during instantiation. Its subsequent choice among array, tuple and ordinary object handling depends on the actual type supplied for that parameter.

This explains why the exact shape of a generic utility matters. The type system recognizes a relationship between a parameter's key set and the properties being produced. A different expression that happens to mention numeric keys need not express the same relationship to the original container.

Tuple construction preserves more than element count

The tagged instantiateMappedTupleType() implementation maps the tuple's element type arguments. For each position, it instantiates the mapped template using the corresponding positional key. It then calls the tuple constructor with the resulting element types and the tuple's structural information.

That information includes minimum length and whether a rest element exists. These properties became relevant to describing parameter lists in TypeScript 3.0. A mapping that preserved only the visible leading element types would lose whether later elements are optional or whether the tuple continues with an open-ended rest portion.

The implementation also accounts for optionality modifiers in the mapped declaration. Adding the optional modifier can reduce the minimum length, while removing it increases the required portion subject to the rest-element arrangement. Without either modifier change, it carries the original minimum length forward.

This is a useful consequence for existing utilities. A transformation asking for required properties should affect required tuple positions consistently, while a transformation that preserves modifiers should retain the input's optional structure. The compiler is interpreting the mapped operation in terms of the container it recognized.

These details come from the tagged implementation, so they describe the release's mechanism without assuming a later tuple feature. The code handles the tuple forms present in this release. It does not establish support for arbitrary arrangements of generic tuple segments merely because the mapped result remains a tuple.

For library declarations, the result can remove repeated arity-specific definitions. A function processing a tuple can name one mapped relationship and let the supplied tuple determine its positions. The library's JavaScript implementation still has to perform an operation whose result satisfies that relationship.

Arrays use an element transformation

The same instantiation code recognizes ordinary arrays and readonly arrays before constructing an anonymous object type. It builds an array whose element type is the instantiated mapped template. This avoids treating methods such as push() as independent values to be transformed by the template.

The distinction between a tuple and an array remains useful. A tuple carries information about individual positions, while an array supplies a general element type. The compiler preserves the category it receives rather than claiming a fixed number of elements for an input that never specified one.

For a generic utility, this means the input type still determines how much structural information can be preserved. A caller supplying an ordinary array cannot expect the mapping to discover a tuple length from runtime data. The compiler transforms the information available in the declared type.

My preference is for generic declarations that preserve information already established at the boundary. A tuple-to-tuple mapping can do that. An assertion inventing a fixed output shape after the input has been widened to an ordinary array would make a different claim, one the generic relationship no longer supplies.

Type mappings do not create runtime promises

The promised-coordinate example is a type transformation. It does not allocate promises or wait for values. An implementation using that result type must provide the corresponding runtime values through its own code.

This separation matters for higher-order APIs. A precise declaration can describe a runtime operation without implementing it. Consumers benefit because the declared result retains positions they can inspect, while the author remains responsible for the actual relationship between input and output.

The change also does not imply that every standard-library declaration immediately adopts every new expressible pattern. A language mechanism and a particular library signature are separate deliverables. The mechanism shown in the tagged checker establishes what a library author can describe, while the library's published declaration determines what a caller actually receives from checking.

Packages can select declarations by compiler version

The release also recognizes another established JavaScript structure, properties assigned to functions. Its release notes describe property declarations made by assignment in the same scope as a function declaration or a constant initialized with a function. A callable value can therefore expose an additional operation without requiring the source to wrap the property in a TypeScript namespace.

This matters during migration because JavaScript already permits functions to carry properties. Requiring a different organizational construct merely to describe that existing arrangement changes the source for the benefit of the checker. Recognizing the assignment pattern allows the declaration model to follow the program more directly. The same capability can describe properties such as defaults attached to function components.

Like tuple mapping, this is a defined recognition rule. The statement concerns assignments in the relevant containing scope and the supported function declarations. It should not be generalized into a promise that arbitrary mutation of callable objects across a whole program produces a permanently precise inferred interface. The compiler needs an identifiable pattern from which to obtain the property information.

TypeScript 3.1 also introduces typesVersions for package declarations. This addresses the problem of publishing newer type syntax while preserving a declaration path for older compiler users. A package can provide multiple declaration layouts and describe which compiler versions should use each one.

{
  "name": "example-package",
  "version": "1.0.0",
  "types": "./index.d.ts",
  "typesVersions": {
    ">=3.1": {
      "*": ["ts3.1/*"]
    }
  }
}

The example gives compatible compilers a redirected declaration path while preserving the ordinary types entry. It assumes the package actually includes the referenced declaration files. The field selects files; it does not generate an older equivalent of newer syntax.

The tagged module resolver iterates the version keys and returns the first range matching the current compiler. Invalid ranges are skipped. Overlapping ranges therefore need deliberate ordering, since a broader earlier match can prevent a later entry from being selected.

That gives declaration maintainers a concrete compatibility tool alongside the new mapping capability. They can publish types using the relationships a newer compiler understands while retaining another declared interface for older consumers. Each selected file set still needs to describe the same delivered JavaScript package accurately, even when its type notation differs.