I have a tuple where the types relate to each other. In my case it's an extractor function that extracts a value that is in turn used as input to another function.
Conceptually what I'm looking for is something like this, but this doesn't compile:
const a: <T>[(v:any) => T, (t:T) => void] = [ ... ]
The use case is this. I have an incoming RPC message of type any
, and an API with well known argument types. I want to build a "wiring plan" that takes two arguments, one extractor function and the corresponding API function.
export interface API = {
saveModel : (model:Model) => Promise<boolean>,
getModel : (modelID:string) => Promise<Model>,
}
const api: API = { ... }
// this is the tuple type where i'd like to define that
// there's a relation between the second and third member
// of the tuple.
type WirePlan = [[string, (msg:any) => T, (t:T) => Promise<any>]]
const wirePlan: WirePlan = [[
['saveModel', (msg:any) => <Model>msg.model , api.saveModel],
['getModel' , (msg:any) => <string>msg.modelID, api.getModel],
]
const handleMessage = (msg) => {
const handler = wirePlan.find((w) => w[0] === msg.name)
const extractedValue = handler[1](msg)
return handler[2](extractedValue)
}
I can work around the issue in other ways, it just struck me there may be something about tuples I haven't understood.
Tuple types are value types; tuple elements are public fields. That makes tuples mutable value types. The tuples feature requires the System. ValueTuple type and related generic types (for example, System.
Tuples can be used in the following scenarios: When you want to return multiple values from a method without using ref or out parameters. When you want to pass multiple values to a method through a single parameter. When you want to hold a database record or some values temporarily without creating a separate class.
Conceptually what I'm looking for is something like this, but this doesn't compile:
const a: <T>[(v:any) => T, (t:T) => void] = [ ... ]
That is, in fact, the opposite of what you want. Drawing on the intuition of function types, a: <T>(t: T) => T
means you have a function that works for all types. This is a universal quantifier: the implementation of a
doesn't know what T
is; the user of a
can set T
to whatever they want. Doing this for your tuple would be disastrous, as the inner functions need to output values of T
no matter what T
is, and therefore the only thing they can do is error out/loop forever/be bottom in some way or another (they must return never
).
You want existential quantification. a: ∃T. [(v:any) => T, (t:T) => void]
means that a
has some type T
associated with it. The implementation of a
knows what it is and can do whatever it likes with it, but the user of a
now knows nothing about it. In effect, it reverses the roles when compared to universal quantification. TypeScript doesn't have support for existential types (not even in a super basic form like Java's wildcards), but it can be simulated:
type WirePlanEntry = <R>(user: <T>(name: string, reader: (msg: any) => T, action: (t: T) => Promise<any>)) => R
type WirePlan = WirePlanEntry[]
Yes, that is a mouthful. It can be decomposed to:
// Use universal quantification for the base type
type WirePlanEntry<T> = [string, (msg: any) => T, (t: T) => Promise<any>]
// A WirePlanEntryConsumer<R> takes WirePlanEntry<T> for any T, and outputs R
type WirePlanEntryConsumer<R> = <T>(plan: WirePlanEntry<T>) => R
// This consumer consumer consumes a consumer by giving it a `WirePlanEntry<T>`
// The type of an `EWirePlanEntry` doesn't give away what that `T` is, so now we have
// a `WirePlanEntry` of some unknown type `T` being passed to a consumer.
// This is the essence of existential quantification.
type EWirePlanEntry = <R>(consumer: WirePlanEntryConsumer<R>) => R
// this is an application of the fact that the statement
// "there exists a T for which the statement P(T) is true"
// implies that
// "not for every T is the statement P(T) false"
// Convert one way
function existentialize<T>(e: WirePlanEntry<T>): EWirePlanEntry {
return <R>(consumer: WirePlanEntryConsumer<R>) => consumer(e)
}
// Convert the other way
function lift<R>(consumer: WirePlanEntryConsumer<R>): (e: EWirePlanEntry) => R {
return (plan: EWirePlanEntry) => plan(consumer)
}
Consuming EWirePlanEntry
looks like
plan(<T>(eT: WirePlanEntry<T>) => ...)
// without types
plan(eT => ...)
but if you just have consumers like
function consume<T>(plan: WirePlanEntry<T>): R // R is not a function of T
you'll use them like
plan(consume) // Backwards!
lift(consume)(plan) // Forwards!
Now, though, you can have producers. The simplest such producer has already been written: existentialize
.
Here's the rest of your code:
type WirePlan = EWirePlanEntry[]
const wirePlan: WirePlan = [
existentialize(['saveModel', (msg:any) => <Model>msg.model , api.saveModel]),
existentialize(['getModel' , (msg:any) => <string>msg.modelID, api.getModel ]),
]
const handleMessage = (msg: any) => {
let entry = wirePlan.find(lift((w) => w[0] === msg.name))
if(entry) {
entry(handler => {
const extractedValue = handler[1](msg)
return handler[2](extractedValue)
})
}
}
In Action
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With