简体   繁体   中英

Typescript - Using generics to extend an interface with a property of union type

I have an interface like this

interface Test {
  values: string[] | number[] | Date[]
  // more properties
}

I would like to create another interface that makes the type specific. What I am trying is:

interface TestWithType<T extends string | number | Date> extends Test {
  values: T[];
}

But this fails with an error saying Type 'string' is not assignable to type 'Date' .

What is the correct syntax for this? If property values was not an array, this would work fine

interface Test {
  values: string | number | Date
}

interface TestWithType<T extends string | number | Date> extends Test {
  values: T;
}

Two possible solutions, using lookup types or conditional types:

enum TestType { STRING, NUMBER, DATE }
interface TestTypeMapping {
  [TestType.STRING]: string[];
  [TestType.NUMBER]: number[];
  [TestType.DATE]: Date[];
}
interface TestWithType1<T extends TestType> extends Test {
  values: TestTypeMapping[T];
}

type TypeToArrayType<T> =
  T extends string ? string[] :
  T extends number ? number[] :
  T extends Date ? Date[] :
  never[];
interface TestWithType2<T extends string | number | Date> extends Test {
  values: TypeToArrayType<T>;
}

As you've noticed, you don't get string[] | number[] | Date[] string[] | number[] | Date[] string[] | number[] | Date[] by apply making an array of elements of type string | number | Date string | number | Date string | number | Date . And since (string | number | Date)[] is wider than string[] | number[] | Date[] string[] | number[] | Date[] string[] | number[] | Date[] , you get a compiler error since you can't widen properties of subtypes.

@MattMcCutchen's answer gives some ways to deal with this. Here's another way:

If you have a union like string | number | Date string | number | Date string | number | Date and want to programmatically turn it into a union of arrays like string[] | number[] | Date[] string[] | number[] | Date[] string[] | number[] | Date[] , you can use distributive conditional types :

type DistributeArray<T> = T extends any ? T[] : never;

Then you can define TestWithType in terms of DistributeArray :

// no error:
interface TestWithType<T extends string | number | Date> extends Test {
  values: DistributeArray<T>;
}

And verify that it behaves as you expect:

declare const testWithString: TestWithType<string>
testWithString.values; // string[]

declare const testWithDate: TestWithType<Date>
testWithDate.values; // Date[]

declare const testWithStringOrNumber: TestWithType<string | number>
testWithStringOrNumber.values; // string[] | number[]

Hope that helps. Good luck!


EDIT:

As a related question, is there any way to forbid passing an union type to the generic? (As in requiring to only specify at most one of string, number or date)

Yeah, that's possible , but it requires abusing the type system in a way that makes me uncomfortable. I'd suggest not doing that if you don't need to. Here it is:

type DistributeArray<T> = T extends any ? T[] : never;
type NotAUnion<T> = [T] extends [infer U] ? U extends any ? 
  T extends U ? unknown : never : never : never
type ErrorMsg = "NO UNIONS ALLOWED, PAL"
interface TestWithType<T extends (
  unknown extends NotAUnion<T> ? string | number | Date : ErrorMsg
)> extends Test {
  values: DistributeArray<T>
}

declare const testWithString: TestWithType<string> // okay

declare const testWithDate: TestWithType<Date> // okay

declare const testWithStringOrNumber: TestWithType<string | number> // error:
// 'string | number' does not satisfy the constraint '"NO UNIONS ALLOWED, PAL"'.

The type NotAUnion<T> evaluates to unknown (a top type) if T is not a union... otherwise it evaluates to never (a bottom type). Then, in TestWithType the type T is constrained to unknown extends NotAUnion<T> ? string | number | Date : ErrorMsg unknown extends NotAUnion<T> ? string | number | Date : ErrorMsg unknown extends NotAUnion<T> ? string | number | Date : ErrorMsg , barely skirting the rules against circular references. If you pass a non-union as T , it becomes T extends string | number | Date T extends string | number | Date T extends string | number | Date as before. If you pass a union, it becomes T extends ErrorMsg , a string literal type. Since a union will never extend a string literal, it will fail... And the error message you get will involve ErrorMsg , so it should hopefully be enough for a developer to realize what's happening. It works, but it's very very sketchy stuff. You asked for it, though.

Good luck again!

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