Reputation: 366
I have a generic entity type, with the generic used to define a field type based on a set of string literals:
type EntityTypes = 'foo' | 'bar' | 'baz';
type EntityMappings = {
foo: string;
bar: number;
baz: Array<string>;
}
type GenericEntity<T extends EntityTypes> = {
type: T;
fieldProperty: EntityMappings[T];
}
What I'm trying to do is require all instances of GenericEntity to have a single type
field (a string literal) that then defines the type of fieldProperty, e.g:
const instance: GenericEntity<'foo'> = {
type: 'foo',
fieldProperty: 'hello',
};
const otherInstance: GenericEntity<'baz'> = {
type: 'baz',
fieldProperty: ['a', 'b', 'c'],
}
However, because T extends EntityTypes
allows for a union of multiple string literal values in EntityTypes, I'm able to do this, which I want to disallow:
const badInstance: GenericEntity<'foo' | 'baz'> = {
type: 'baz',
fieldProperty: 'blah',
};
This compiles because now type
is of type 'foo' | 'baz'
and fieldProperty is of type string | Array<string>
, but the two fields no longer correspond like I intend them to.
Is there a way to restrict the generic declaration on GenericEntity further, to only allow a single unique string literal value? Barring that, is there some other way to insist that any instance of GenericEntity has a type
field and a fieldProperty
field that correspond?
Upvotes: 5
Views: 2421
Reputation: 329398
There is currently no direct way to restrict a generic type parameter to a single member of a union. There's an open feature request at microsoft/TypeScript#27808 to support something like T extends oneof EntityTyes
, but that's not implemented yet. If you want to see it happen you might visit that issue and give it a 👍, but I don't know it would have much effect.
That means T extends EntityTypes
could allow T
to be any subtype of EntityTypes
, including the full EntityTypes
union. In practice this tends not to be a huge deal, since usually such T
does get inferred as a single member (people often call foo("x")
or foo("y")
instead of foo(Math.random()<0.5?"x":"y")
). But sometimes it causes problems, especially with example code like yours.
So how can we work around this? Given your particular example code, I'd say that you want GenericEntity
to actually be more like a discriminated union with three members, instead of a generic type. But you can get both, via a distributive object type as coined in microsoft/TypeScript#47109. It looks like this:
type GenericEntity<T extends EntityTypes = EntityTypes> = { [U in T]: {
type: U;
fieldProperty: EntityMappings[U];
} }[T]
We are taking the type T
passed in and mapping over its members, and then indexing into it with T
. This has no real effect if T
is a single string literal, but when it's a union, the result is also a union without any of the undesirable "cross-correlated" terms:
type GE = GenericEntity;
/* type GE = {
type: "foo";
fieldProperty: string;
} | {
type: "bar";
fieldProperty: number;
} | {
type: "baz";
fieldProperty: string[];
} */
(I also made a generic parameter default for T
so GenericEntity
without a type argument is the full union we actually want here.)
So what we're doing is: instead of prohibiting unions in T
, we are handling them by distributing over them.
Now things will behave as you desire:
const instance: GenericEntity<'foo'> = {
type: 'foo',
fieldProperty: 'hello',
} // okay;
const otherInstance: GenericEntity<'baz'> = {
type: 'baz',
fieldProperty: ['a', 'b', 'c'],
} // okay
const badInstance: GenericEntity<'foo' | 'baz'> = {
type: 'baz',
fieldProperty: 'blah',
}; // error!
Looks good!
Upvotes: 8