Proper IntelliSense for function that takes and re

2019-06-11 05:51发布

问题:

I am trying to write a TypeScript type definition for a function that receives and returns heterogeneous types, but am struggling to get the compiler to output the right types. I am hoping someone well-versed in this could help.

Suppose I have two classes, a Zebra and a Dog, defined as follows:

declare class Zebra {
    constructor(id: number); 
    getDescription(): {
        "type": string;
        "height": number;
    }
}

declare class Dog {
    constructor(id: number);
    getDescription(): {
        "type": string;
        "tailWagSpeed": number;
        "dailyCupsOfFood": number;
    }
}

 

Now, suppose I want to create a type definition for a function, "fetchAnimalData", which accepts a dictionary of key-value pairs (for data that I want to retrieve in one network roundtrip to a database). A typical call may look as follows:

let result = await fetchAnimalData({
    fido: new Dog(11),
    zeebs: new Zebra(12344)
});
let { zeebs, fido } = result;

console.log("Fido's tail-wagging speed is " + fido.tailWagSpeed);
console.log("Meanwhile, Zeebs' height is " + zeebs.height);

 

As you can see, the fetchAndTransform function takes key-value pairs. The key is just any arbitrary string (which will later be used for assigning variables to). The value is a heterogenous mix of types (though if we need to have them inherit from a common Animal class, that's fine). The result of the function call should be a Promise for a strongly-typed dictionary with the same key names, but with values that correspond to the "getDescription" type of that particular animal.

Is it possible to get TypeScript to do strongly-typed inferencing for this? In particular, I want to make sure I see the IntelliSense suggestion for "tailWagSpeed" when I type "fido.__".

 

PS: For bonus points, taking it one step further: what if I the values becomes a Touple?:

let result = await fetchAndTransform({

        zebraData: [new Zebra(12344)],

        dogTailSpeed: [new Dog(11), (description) => description.dogTailSpeed]

});

let { zebraData, dogTailSpeed } = result;

console.log("Dog 11's tail-wagging speed is " + dogTailSpeed);

console.log("Meanwhile, Zebra 12344's height is " + zebraData.height);

In this fetchAndTransform version, the value of each item in the dictionary is now a Touple that takes an animal and optionally a transform function. The transform function receives the properly-strongly-typed object, and can extract out the specific data it needs. Thus, the result of fetchAndTransform becomes a strongly-typed dictionary with the same keys as before, but with values that now correspond either to the "getDescription" type for the particular animal, or to the extracted data.

 

If it's possible to do either or both in TypeScript, I would be absolutely delighted to learn how. Thanks!

 

UPDATE 1: Thanks to @jcalz for the awesome answer. One thing I'm curious about is why does TypeScript type inferencing stop at a particular point. For example, suppose I have the following (which still doesn't answer the "bonus points" question, but tries to head in that direction)

declare function fetchAnimalData2<T>(
    animalObject: { [K in keyof T]: [{ getDescription(): T[K] }, (input: T[K]) => any] }
): Promise<T>;

By making fetchAnimalData2 take in an Tuple, whose type for the 2nd parameter is (input: T[K]) => any, I suddenly am having this second use of T[K] override the first. That is, if I do:

let result2 = await fetchAnimalData2({
    zebraZeebs: [new Zebra(134), (r) => 5]
});

Then result2.zebraZeebs.___ returns an any type, because it went off of my lack of typing on the (r) => 5 expression instead of the new Zebra(123) expression). If I want the scenario to work right, I actually end up needing to type the Tuple at the calling side as (i: { type: string; height: number }) => ___, which kind of defats the purpose:

let result2 = await fetchAnimalData2({
    zebraZeebs: [new Zebra(134), (i: { type: string; height: number }) => 5]
});

Is there a way for TypeScript to take precedence for the first item in the Tuple instead of the 2nd? Or provide a hint to the compiler, saying that the 2nd argument extends from the result of the first, or something like that?... Thanks!

 

UPDATE 2: @artem also had an awesome answer, in part because he questioned whether my design was correct to begin with (is a tuple really the most usable structure for this, instead of a function?). If I would go with a function -- which usability-wise is as good or better -- the language offers a lot more flexibility.

For those who are curious with what I ended up choosing, here is my modified (and somewhat simplified for my use-case) version of @artem's answer. I still wanted to have instances of my animals (let's think of them as proxy-animals, standing in for real ones in my database), and I didn't need the implementation code, just the declaration, so I only kept the declare-s. So, without further ado, here is a version of the answer that probably most closely resembles what I wanted, even if I didn't know that I wanted it!

interface AnimalRequest<D, Result = D> {
    _descriptionType: D[]; // use empty arrays just to encode the types
    _resultType: Result[];
    transform<R>(transform: (animal: D) => R): AnimalRequest<D, R>;
}

declare function request<D>(c: { getDescription(): D }): AnimalRequest<D>;

declare function fetchAnimalData<RM extends {[n in string]: AnimalRequest<{}>}>(rm: RM)
  : Promise<{[n in keyof RM]: RM[n]['_resultType'][0]}>;

async function test(): Promise<any> {
    const result = await fetchAnimalData({
        fido: request(new Dog(3)),
        zebraZeebsHeight: request(new Zebra(123)).transform(r => r.height)
    })
    let { fido, zebraZeebsHeight } = result;
    console.log(fido.tailWagSpeed);
    console.log(zebraZeebsHeight);
}

回答1:

Before I go for any bonus points I might as well try to answer the first question. Yes, it's possible to get what you want, using mapped types and inference from mapped types:

declare function fetchAnimalData<T>(
  animalObject: {[K in keyof T]: { getDescription(): T[K] }}
): Promise<T>;

Basically think of the output type as T (well, a Promise<T>), and imagine what kind of input type would work. It would be something with the same keys as T, but for each key K, the input property should be something with a getDescription() method that returns a T[K]. Let's see it in action:

async function somethingOrOther() {
  let result = await fetchAnimalData({
    fido: new Dog(11),
    zeebs: new Zebra(12344)
  });
  let { zeebs, fido } = result;

  console.log("Fido's tail-wagging speed is " + fido.tailWagSpeed);
  console.log("Meanwhile, Zeebs' height is " + zeebs.height);
}

It works! Hope that helps!

If I think of how to get the tuple thing working I'll let you know.


Update 0

Okay, I can't think of a great way to type that tuple craziness, mostly because it involves a union of either the one-element tuple ("single"?) of just an animal, or the two-element tuple ("pair", I guess) of an animal and a transformer function, where the type of the transformer function depends on the the type of the description of the animal, which requires something like existential types (more info upon request) and TypeScript's inference won't work here.

However, let me introduce the "artificial tuple":

function artificialTuple<D, V>(animal: { getDescription(): D }, transformer: (d: D) => V): { getDescription(): V } {
  return {
    getDescription: () => transformer(animal.getDescription())
  }
}

You pass in two arguments: the animal, and the transformer function, and it spits out a new pseudo-animal which has already been transformed. You can use it like this, with the old fetchAnimalData() function:

async function somethingElse() {
  let result = await fetchAnimalData({
    zebraData: new Zebra(12344),
    // look, see a typo on the next line!  Thanks TS!
    dogTailSpeed: artificialTuple(new Dog(11), (description) => description.dogTailSpeed)
  });

  let { zebraData, dogTailSpeed } = result;
  console.log("Dog 11's tail-wagging speed is " + dogTailSpeed);
  console.log("Meanwhile, Zebra 12344's height is " + zebraData.height);
}

Note how TS catches the typo with dogTailSpeed not being a property of a Dog's description. That's because the artificalTuple() function requires that the transformer function argument act on the result of getDescription() from the animal argument. Change description.dogTailSpeed to description.tailWagSpeed and it works.

Okay, I know this isn't precisely what you asked for, but it works just as well and plays nicely with TypeScript. Not sure if I get my bonus points or just a participation award. Cheers!


Update 1

@MichaelZlatkovsky said:

suppose I have the following...
declare function fetchAnimalData2(
    animalObject: { [K in keyof T]: [{ getDescription(): T[K] }, (input: T[K]) => any] }
): Promise;

Ah, but that's not the right type, if I understand what you want the return type to be. The values of the properties of T should be the output type of the transformer function, right? So the second element of the tuple needs to look like (input: ???) => T[K], not (input: T[K]) => ???.

So we must change it to something like the following:

declare function fetchAnimalData2<T>(
    animalObject: { [K in keyof T]: [{ getDescription(): ??? }, (input: T[K]) => ???] }
): Promise<T>;

This will kind of work if you replace ??? with any, but you lose the important restriction that the transformer function should act on the type returned by the animal's getDescription() method. That's why I introduced artificialTuple(), which uses generic V to replace ??? and guarantees that the getDescription() method output and the transformer function input are compatible (and caught that typo above).

The type that you are trying to describe to TypeScript is something like this:

declare function fetchAnimalData2<T, V>(
    animalObject: { [K in keyof T]: [{ getDescription(): V[K] }, (input: V[K]) => T[K]] }
): Promise<T>;

where V the mapping from key name to getDescription() output type. But TypeScript has no idea how to infer V from the passed in animalObject value, and in practice it becomes {} which is no better than any.

You can't really do much better than my suggestion (other than changing artificialTuple()'s name to something like transformAnimal() I guess). You want to tell TypeScript that you don't care about V or V[K] as long as there's some type that works, and you don't want to specify it. That's called an existential type and few languages support it.

Okay, good luck again!



回答2:

I think I understand your general idea, and I think it's doable if you agree to tweak your requirements a bit.

First of all, I don't see the reason why you want to pass instances of Dog and Zebra to fetchAnimalData - after all, fetch is supposed to get these instances and return them, not receive them as parameters.

Second, I don't think that using tuples to encode the request is the best idea - how someone who reads the code is supposed to know what each tuple element means?

One possible way of doing these kind of tasks in TypeScript is to encode request as appropriately typed object, containing just enough data to actually perform the request. In the code below, it's AnimalRequest interface. Then you can have a map of these request objects, and it's relatively easy to obtain type for a map of result objects from type of fetchAndTransform parameter - in the code below it's done by AnimalResultMap type.

Note that the very first problem in your code, before any issues with tuples, is that r has any type in (r) => 5, so the transformer is not typechecked. One way to ensure that proper type is inferred for r is to represent optional transform by calling explicit request modifier - withTransform in the code below.

declare class Zebra {
    constructor(id: number); 
    getDescription(): {
        "type": string;
        "height": number;
    }
}

declare class Dog {
    constructor(id: number);
    getDescription(): {
        "type": string;
        "tailWagSpeed": number;
        "dailyCupsOfFood": number;
    }
}

interface AnimalRequest<D, Result = D> {
    id: number;
    descriptionType: D[]; // use empty arrays just to encode the types
    resultType: Result[];
    transform?: (animal: D) => Result; 
    withTransform<R>(transform: (animal: D) => R): AnimalRequest<D, R>;
}

type AnimalClass<D> = { new(id: number): { getDescription(): D } }

// encode the request
function request<D>(c: AnimalClass<D>, id: number): AnimalRequest<D> {
    return {
        id,
        descriptionType: [] as D[],
        resultType: [] as D[],
        withTransform<R>(transform: (animal: D) => R): AnimalRequest<D, R> {
            return Object.assign({}, this, {
                resultType: [] as R[],
                transform
            });
        }
    }
}

type AnimalRequestMap = {[n in string]: AnimalRequest<{}>}
type AnimalResultMap<RM extends AnimalRequestMap> =
    {[n in keyof RM]: RM[n]['resultType'][0]}


declare function fetchAnimalData<RM extends AnimalRequestMap>(rm: RM)
  : Promise<AnimalResultMap<RM>>;

async function f(): Promise<{}> {
    const result = await fetchAnimalData({
        dog: request(Dog, 3),
        zebraZeebs: request(Zebra, 134).withTransform(r => 5)
    })

    const t = result.dog.tailWagSpeed;
    const z = result.zebraZeebs; // number
    return {};
}