I'm trying to constrain the input of a generic to be one of several types. The closest notation I've found is using union types. Here is a trivial example:
interface IDict<TKey extends string | number, TVal> {
// Error! An index signature parameter type must be
// a 'string' or a 'number'
[key: TKey]: TVal;
}
declare const dictA: IDict<string, Foo>;
declare const dictB: IDict<number, Foo>;
What I'm looking for, in this example, is a way to say that TKey
should be either string
or number
, but not the union of them.
Thoughts?
Note: This is a specific case of a broader question. For example, I have another case where I have a function that accepts text
which can be either a string
or StructuredText
(parsed Markdown), transforms it, and returns exactly the corresponding type (not a subtype).
function formatText<T extends string | StructuredText>(text: T): T {/*...*/}
Technically I could write that as an overload, but that doesn't seem like the correct way.
function formatText(text: string): string;
function formatText(text: StructuredText): StructuredText;
function formatText(text) {/*...*/}
An overload also proves problematic, because it won't accept a union type:
interface StructuredText { tokens: string[] }
function formatText(txt: string): string;
function formatText(txt: StructuredText): StructuredText;
function formatText(text){return text;}
let s: string | StructuredText;
let x = formatText(s); // error
Updated for TS3.5+ on 2019-06-20
K extends string | number
for the index signature parameter:Yeah, this can't be done in a very satisfying way. There are a few issues. The first is that TypeScript only recognizes two direct index signature types: [k: string]
, and [k: number]
. That's it. You can't do a union of those (no [k: string | number]
), or a subtype of those (no [k: 'a'|'b']
), or even an alias of those: (no [k: s]
where type s = string
).
The second issue is that number
as an index type is a weird special case that doesn't generalize well to the rest of TypeScript. In JavaScript, all object indices are converted to their string value before being used. That means that a['1']
and a[1]
are the same element. So, in some sense, the number
type as an index is more like a subtype of string
. If you are willing to give up on number
literals and convert them to string
literals instead, you have an easier time.
If so, you can use mapped types to get the behavior you want. In fact, there is a type called Record<>
that's included in the standard library that is exactly what I'd suggest using:
type Record<K extends string, T> = {
[P in K]: T;
};
type IDict<TKey extends string, TVal> = Record<TKey, TVal>
declare const dictString: IDict<string, Foo>; // works
declare const dictFooBar: IDict<'foo' | 'bar', Foo>; // works
declare const dict012: IDict<'0' | '1' | '2', Foo>; // works
dict012[0]; // okay, number literals work
dict012[3]; // error
declare const dict0Foo: IDict<'0' | 'foo',Foo>; // works
Pretty close to working. But:
declare const dictNumber: IDict<number, Foo>; // nope, sorry
The missing piece getting number
to work would be a type like numericString
defined like
type numericString = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7' // ... etc etc
and then you could use IDict<numericString, Foo>
which would behave like you want IDict<number, Foo>
to. Without a type like that, there's not much point trying to force TypeScript to do this. I'd recommend giving up, unless you have a very compelling use case.
I think I understand what you want here. The idea is that you'd like a function that takes an argument of a type that extends a union like string | number
, but it should return a type which is widened to one or more of the elements of that union. You're trying to avoid an issue with subtypes. So, if the argument is 1
, you don't want to commit to outputting a 1
, just a number
.
Before now, I'd say just use overloads:
function zop(t: string): string; // string case
function zop(t: number): number; // number case
function zop(t: string | number): string | number; // union case
function zop(t: string | number): string | number { // impl
return (typeof t === 'string') ? (t + "!") : (t - 2);
}
This behaves how you'd like:
const zopNumber = zop(1); // return type is number
const zopString = zop('a'); // return type is string
const zopNumberOrString = zop(
Math.random()<0.5 ? 1 : 'a'); // return type is string | number
And that's the suggestion I'd give if you just have two types in your union. But that could get unwieldy for larger unions (e.g., string | number | boolean | StructuredText | RegExp
), since you need to include one overload signature for every nonempty subset of elements from the union.
Instead of overloads we can use conditional types:
// OneOf<T, V> is the main event:
// take a type T and a tuple type V, and return the type of
// T widened to relevant element(s) of V:
type OneOf<
T,
V extends any[],
NK extends keyof V = Exclude<keyof V, keyof any[]>
> = { [K in NK]: T extends V[K] ? V[K] : never }[NK];
Here is how it works:
declare const str: OneOf<"hey", [string, number, boolean]>; // string
declare const boo: OneOf<false, [string, number, boolean]>; // boolean
declare const two: OneOf<1 | true, [string, number, boolean]>; // number | boolean
And here's how you can declare your function:
function zop<T extends string | number>(t: T): OneOf<T, [string, number]>;
function zop(t: string | number): string | number { // impl
return (typeof t === 'string') ? (t + "!") : (t - 2);
}
And it behaves the same as before:
const zopNumber = zop(1); // 1 -> number
const zopString = zop('a'); // 'a' -> string
const zopNumberOrString = zop(
Math.random()<0.5 ? 1 : 'a'); // 1 | 'a' -> string | number
Whew. Hope that helps; good luck!