简体   繁体   中英

TypeScript type annotation to express variable numbers of generics?

I've been converting a project of mine from JavaScript to TypeScript, but this type annotation has me stumped. I have a Serializer interface and a class for combining these interfaces like the following:

interface Serializer<T> {
    serialize(value: T): ArrayBuffer
}

class UnionSerializer {
    constructor(types) {
        this.types = types
    }
    serialize(value) {
        for (const type of this.types) {
            try {
                return type.serialize(value) //throws error if type can't serialize value
            }
            catch (e) {}
        }
        throw new Error('No type matched')
    }
}

How would I go about writing a type declaration for UnionSerializer based on Serializer ? Clearly its type depends on the types array passed into it. So it has an interface like:

interface UnionSerializerConstructor {
    new <T1>([t1]: [Serializer<T1>]): Serializer<T1>
    new <T1, T2>([t1, t2]: [Serializer<T1>, Serializer<T2>]): Serializer<T1 | T2>
    //...
}

What is the most elegant way to write type annotations for the UnionSerializer class, ideally without writing out all of the constructor annotations for different lengths of the types array?

Is there a difference between the type of new UnionSerializer([a,b]) and the type of new UnionSerializer([b,a]) ? It doesn't really look like it, given that your type signatures for UnionSerializerConstructor return a Serializer<A | B> Serializer<A | B> which would be the same as Serializer<B | A> Serializer<B | A> . In that case, good news! You don't need to worry about specifying an generic arbitrary-length tuple type, which you can't do yet . Instead, just accept an array:

interface UnionSerializerConstructor {
    new <T>(types: T[]): Serializer<T>
}

If you pass in [a,b] (where a is an A and b is a B ) to the constructor, it will infer A | B A | B for T , as you want.


Now, it looks like you expect the values passed into the constructor to themselves be Serializer instances. In that case, you should probably do this instead:

interface UnionSerializerConstructor {
    new <T>(types: (Serializer<T>)[]): Serializer<T>
}

I might as well go ahead and type your UnionSerializer for you:

class UnionSerializer<T> implements Serializer<T> {
  constructor(public types: (Serializer<T>)[]) {
    // next line is unnecessary with "public types" above
    // this.types = types
  }
  serialize(value: T): ArrayBuffer {
    for (const type of this.types) {
      try {
        return type.serialize(value) //throws error if type can't serialize value
      }
      catch (e) { }
    }
    throw new Error('No type matched')
  }
}

Let me know if you need me to explain any of that. Hope it helps. Good luck!


Update 1

@csander said :

Thanks for the answer! This was my original solution. The problem I have though is that not all the types I pass in will necessarily be Serializer<T> with the same T . For example, I might have a Serializer<string> and a Serializer<number> , so the desired T for the union would be string | number string | number but neither of the original Serializer s implement Serializer<string | number> Serializer<string | number> . Does that make sense?

That is an excellent point and I failed to address it. The short answer is that in TypeScript generic type parameters are covariant , meaning that, in fact, you can always narrow a Serializer<string|number> value to one of type Serializer<string> :

declare const numberOrStringSerializer: Serializer<number | string>;
const justStringSerializer: Serializer<string> = numberOrStringSerializer;
const justNumberSerializer: Serializer<number> = numberOrStringSerializer;

(This type of narrowing is unsound in general, but TypeScript does it for reasons of performance and developer convenience. There are possible fixes for it but they're not part of the language yet.)

That being said, type inference on the UnionSerializer constructor will not automatically infer the union type :

new UnionSerializer([justNumberSerializer, justStringSerializer]); // error

You can manually specify it, however:

new UnionSerializer<string|number>([justNumberSerializer, justStringSerializer]); // ok

This is a bit annoying, but it works. There may be ways to improve this experience but I'm not sure at the moment. Does this help?

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM