July 19, 2019·4 min read·engineeringtypescripttypesgenerators

TypeScript 3.6 beta separates generator inputs and outputs

Generator types now distinguish yielded values, terminal returns and values supplied by next. Iterator results use done to preserve that distinction in control flow.

A generator exchanges values in both directions. It yields values to its caller, receives values when the caller resumes it, and eventually returns a terminal result. Treating those values as one undifferentiated type loses information at precisely the points where the two sides need to agree.

The July 19 announcement of TypeScript 3.6 beta describes stricter generator checking through separate type parameters. It also changes iterator results into a discriminated union. The checker can now connect the done flag to the meaning of the accompanying value.

The published beta package contains the Generator declaration in its ES2015 library definitions. Its parameters represent the yielded type, returned type and input accepted by next. That definition turns an informal exchange protocol into a type relationship callers can inspect.

I find this improvement useful because a type system should describe the actual direction of data flow. A generator is a compact way to express stateful interaction. Its compactness should not require the caller to guess which values are legal when resuming it.

A yielded value is different from the expression's result

In a generator, the value after yield goes outward. When execution resumes, the yield expression can evaluate to a value supplied by the caller. Those values can have different types. The terminal return can have yet another type.

An explicit signature makes the distinction visible.

function* sequence(): Generator<number, string, boolean> {
    let value = 0;
    while (true) {
        const stop = yield value++;
        if (stop) {
            return "finished";
        }
    }
}

const iterator = sequence();
const first = iterator.next();
const last = iterator.next(true);

This generator yields numbers, accepts Boolean values through next, and returns a string when the supplied value requests termination. The first call starts execution. The later call supplies the value received by the suspended yield expression.

The third type parameter does not describe the yielded number. It describes the input used to resume the generator. That distinction prevents a caller from assuming that whatever came out is also an acceptable value to send back.

The announcement demonstrates inference from a typed use of yield inside the generator. If the resumed value is assigned to a string variable, passing a number to next can now produce an error. An explicit Generator annotation is useful when the intended exchange should be stated independently of inference.

Done selects the result's meaning

IteratorResult now represents two alternatives. A yield result has a value of the yielded type and a done field that is false or absent. A return result has done set to true and a value of the terminal return type.

That design preserves a correlation. The same property name, value, carries different information depending on whether iteration has completed. Checking done gives the compiler evidence about which alternative is present, so a caller can use the corresponding operations.

A terminal string does not need to remain mixed with every number yielded during the sequence once the caller has established completion. Earlier checking could retain that unnecessary union. The beta's declaration makes the distinction part of ordinary control-flow narrowing.

This also places responsibility on callers that inspect iterator results directly. Reading value before checking completion can still produce a union. The checker needs the same evidence the runtime protocol supplies, so the new declaration does not make the flag irrelevant.

Next has a zero-or-one-argument contract

The declaration uses a rest parameter whose type is a union of an empty tuple and a one-element tuple. That expresses next being callable with no argument or one input of the declared type. The package comment explains that this form supports the intended assignability diagnostics.

The zero-argument case is relevant when starting a generator. There is no preceding suspended yield expression to receive a value on the initial call. Keeping that case expressible does not mean every arbitrary input becomes legal on later calls.

Generator also extends Iterator while guaranteeing return and throw methods and making itself iterable. Those additional members distinguish the generator interface from an iterator whose corresponding methods are optional. Library authors can choose the interface that reflects what their consumer actually requires.

The defaults still matter. Generator's declaration supplies unknown for its yielded and next-input defaults and any for the return default. Supplying explicit parameters can communicate a narrower contract when a library knows those values. The release should not be described as removing every permissive iterator default.

The beta exposes a library contract for review

The announcement calls the beta intended to be feature complete. It remains a prerelease, so the useful action for library authors is examining how their declared iterator contracts interact with the new checking. There is now a concrete implementation available instead of only a proposal about stronger types.

The same announcement includes more accurate array spread for older targets and improved diagnostics around missing await operations. Those changes concern different boundaries, including emitted JavaScript behavior. The generator improvement itself is about the type model of an existing protocol.

I prefer the new model because it reduces ambiguity without inventing a runtime framework. A generator can retain its ordinary JavaScript execution while its signature states the kinds of data exchanged. Callers then use done to distinguish a yielded item from the terminal result, and next checks the input traveling in the opposite direction.