So, I'm working on a library to manage pipelines of transformations (from a source, through several "gaskets", to sinks)... and running into a problem specifically with recursive types. Supposedly that's been possible since TS 3.7, but I think I've run into an edge case that ain't.
interface Sink<E> { seal():void; send(value:E):void; }
type Gasket<I,O> = (target:Sink<O>) => Sink<I>;
/**
* The type of arrays of gaskets which go from a source type `I` to a sink type `O`.
*/
type Gaskets<I,O,Via=never> =
// An array containing a single gasket from I to O.
| [Gasket<I,O>]
// A gasket from I to Via, followed by gaskets which go from Via to O.
| [Gasket<I,Via>, ...Gaskets<Via, O>]
;
/**
* `Join()` is used to cleanly wrap a single sink, or to assemble a sequence of
* gaskets which goes from type I to type O and into a Sink<O> at the end.
*/
export function Join<I,O=never>(...arg: [Sink<I>]): Sink<I>;
export function Join<I,O>(...arg: [...Gaskets<I,O>, Sink<O>]): Sink<I>;
// @ts-ignore-error
export function Join<I,O>(...arg) {
// ...
}
The above... doesn't work. Just Gaskets<Via,O>
is wrong, but at least doesn't pitch an error... but when I add the spread-operator ...
prefix, it pitches Gaskets is not generic ts(2315)
at that token, and Type alias Gaskets circularly references itself ts(2456)
on the first line where I declare type Gaskets
itself.
I've got a temporary workaround, but it basically means manually unrolling the recursion, and therefore supports a limited number of elements. Here it is unrolled to 7 gaskets...
type Gaskets<I,O,V1=never,V2=never,V3=never,V4=never,V5=never,V6=never> =
| [Gasket<I,O>]
| [Gasket<I,V1>, Gasket<V1, O>]
| [Gasket<I,V1>, Gasket<V1,V2>, Gasket<V2, O>]
| [Gasket<I,V1>, Gasket<V1,V2>, Gasket<V2,V3>, Gasket<V3, O>]
| [Gasket<I,V1>, Gasket<V1,V2>, Gasket<V2,V3>, Gasket<V3, V4>, Gasket<V4,O>]
| [Gasket<I,V1>, Gasket<V1,V2>, Gasket<V2,V3>, Gasket<V3, V4>, Gasket<V4,V5>, Gasket<V5,O>]
| [Gasket<I,V1>, Gasket<V1,V2>, Gasket<V2,V3>, Gasket<V3, V4>, Gasket<V4,V5>, Gasket<V5,V6>, Gasket<V6,O>]
;
Here's a simplified example of how it's intended to be used, from my specs...
describe("Join", () => {
it("builds multi-element transformation chains", () => {
const receiver = jest.fn((e:string) => {});
const fromString = (i:string) => parseFloat(i);
const toString = (i:number) => String(i);
const increment = (i:number) => i+1;
const chain = P.Join(
P.Map(fromString),
P.Map(increment),
P.Map(increment),
P.Map(toString),
P.CallbackSink(receiver)
);
chain.send("5");
chain.send("-2");
chain.seal();
expect(receiver.mock.calls).toMatchObject([
["7"], ["0"]
]);
})
});
Now, I could just offer 2-argument or 1-to-N-argument forms of Join that only connects two nodes at a time, and write it up as Join(gasket, Join(gasket, Join(gasket, sink))
and so on... but I'd really like to improve the signal-to-noise ratio.
Am I missing a trick, or this is fundamentally impossible presently?
EDIT: here is an error-free TS playground demonstrating the unrolled-type version of the code. The other definition for type Gaskets<I,O>
can be substituted in to see the problem.
EDIT: here's a refactoring using @jcalz example code, which works the way I expect, but with some unexpected errors inside the Join()
implementation. type OGaskets
is utter witchcraft, and while I can follow everything else going on, that bit, and how it contributes to the whole, is incomprehensible to me at this moment.