Search code examples
typescriptwhatwg-streams-api

how to make sure a ReadableStream piped into a WritableStream emits the right type?


I have a WritableStream that expects objects of the shape { test1: string; test2: string }.

I have a ReadableStream that emits objects of the shape { test1: string }.

Piping the readable stream into the writable stream will cause problems, as the objects emitted by the readable stream are lacking the test2 property.

However, the statement new ReadableStream<{ test1: string }>().pipeTo(new WritableStream<{ test1: string; test2: string }>) does not cause any type errors.

Why does the statement not emit any type errors? Is there a way how I can define my WritableStream so that it causes type errors when incompatible data is piped into it?


Solution

  • The types WritableStream<{ a: string; b: string }> and WritableStream<{ a: string }> ultimately differ in the signature of their getReader().write(chunk) method.

    A very simple reproduction of this unexpected behaviour would be this:

    // No type error:
    const writer: { write(chunk: { a: string }): void } = {
        write(chunk: { a: string; b: string }) => { }
    };
    writer.write({ a: "string" }); // No type error
    

    TypeScript also doesn’t show an error here. This has to do with a concept called bivariance and contravariance.

    In TypeScript, a function is always assignable to a function with more narrow parameter types. For example, this is a valid TypeScript statement:

    // No type error
    const func: (arg: { a: string; b: string }) => void = (arg: { a: string }) => {};
    

    This makes sense, as the implementation does not need the b property, so calling it with an { a, b } object won’t cause any problems.

    However, there are also cases where a function is assignable to a function with less narrow parameter types:

    // Sometimes a type error
    const func: (arg: { a: string }) => void = (arg: { a: string; b: string }) => {};
    

    This statement creates a type error for contravariant functions, but it does not create an error for bivariant functions.

    In TypeScript, there is no explicit syntax to mark a function as contravariant or bivariant. Instead, its variance is decided as follows:

    • If strictFunctionTypes (which is included in strict) is set to true, only methods are bivariant, while all other functions (including function properties) are contravariant (this is also mentioned in the TypeScript FAQ, which also contains a lot of details about function variance).
    • Otherwise, all functions are bivariant.

    At first sight it might seem like bivariance does not make sense, but there are actually many cases where it does. Let’s take this example:

    function sum(array: Array<{ a: number }>) {
        return array.reduce((a, c) => a + c.a, 0);
    }
    
    const array: Array<{ a: number; b: number }> = [];
    console.log(sum(array));
    

    Instinctively, you expect this example to work, since sum() only needs access to the a property of the provided array. But when we look at the .push() property of the array, we discover the same problem that we had with writable streams: Inside the sum() function, we can call array.push({ a: 1 }) without an error, which would add an invalid value to the array. The code doesn’t show an error because Array.push() is bivariant. When our sum() function only reads the array properties, we would expect it to be bivariant, but when we call it’s methods, we would expect it to be contravariant, but TypeScript doesn’t provide a way to distinguish between those cases.

    For arrays this case cannot be solved, since it depends on the context whether their methods should be bivariant or contravariant. For writable streams, I think they should always be contravariant, since it is not possible to read any properties from them. To achieve contravariance, the WritableStreamDefaultWriter class could declare its write() function as a property instead of a method:

    type Writer = {
        write: (chunk: { a: string }) => void;
    }
    

    I have also raised this as a TypeScript issue.