Description
Problem
In the cycle.js project, data-flow is implemented by defining functions of the type:
type CreateDataFlow<Source, Sink> = (sources: Source)=>Sink
Where Source
and Sink
are interfaces where every member is a property of type Observable
.
Each implementation of CreateDataFlow
will specify Interfaces (typically without index types) for Source
and Sink
.
Currently, it is not possible to specify this contract using types.
This is a problem because the contract is not enforced by types, and instead relies on the developer to implement their data-flow function correctly. More specifically, this is a problem when dealing with higher-order code that takes implementations of CreateDataFlow
, that wants to deal with the input/output generically (i.e. without knowing what the keys/members are at compile time, it wishes to iterate over them knowing only that the type is Observable
).
Existing Solutions
There are two options:
The first is the one illustrated above. I.e., where the type-parameters Source
and Sink
have no type-restrictions. This creates the situation where any function that takes a parameter will be accepted. It's not clear from the typing what the contract should be, and so it will be trivially easy to pass in an invalidly typed function. If we wish to iterate over the output of the implementation, for example, we have to cast it to an index type (although, type guards do at least make this runtime safe).
The second option is to try something like:
type CreateDataFlow<Source extends {[key:string]:Observable<any>}, Sink extends {[key:string]:Observable<any>}> = (source:Source)=>Sink
The problem is, this doesn't actually adhere to the contract: If we try to use an implementation where Source
is an Interface (with all members having type Observable
), an error is thrown by the compiler. Using this approach we are forced to weaken the contract to index-types only, which means we cannot differentiate (at a type-level) between implementations, and indeed allows callers of an implementation of CreateDataFlow
to pass in any conforming instance (although, again type-guards do make this runtime safe).
Neither solution adequately captures the contract, and requires use of type-guards to make type-safe. Needless to say, runtime type-safety is not nearly as nice as compile time, otherwise we'd all be using JavaScript instead.
Proposal: Allow string literals in index types
The proposal is that the following code would be valid:
type ObservableMap <Keys extends string> = {[keys:Keys]: Observable<any>}
And so the definition of CreateDataFlow
becomes:
type CreateDataFlow<SourceKeys, SinkKeys> = (source: ObservableMap<SourceKeys>) => ObservableMap<SinkKeys>
Sub-types would be interfaces, and Key would be a string-literal defining the members, e.g.:
interface MySource {
a: Observable<string>
b: Observable<number>
}
type MySourceKey = 'a'|'b'
interface MySink {
c: Observable<boolean>
d: Observable<void>
}
type MySinkKey = 'c'|'d'
const myDataFlow: CreateDataFlow<MySourceKey, MySinkKey> = (mySource: MySource)=>MySink {
// ...
}
Potentially something akin to #7722 could be used to manage these "key" definitions.