Reputation: 8508
Is it possible to define a type for an object, where one keys type takes reference on the values of another key?
Let's say we want to have a type Configuration
where languages
can be defined. How can I make sure that the key defaultLanguage
is one of the languages? The languages
should act as union type.
It is possible to write..
export const languages = ['de-CH', 'en-GB'] as const;
type Language = typeof languages[number];
export const defaultLanguage: Language = 'de-CH';
.., but is it also possible to write the type for an object, which represents the same without extract the languages
?
type Configuration = {
languages: string[],
defaultLanguage: // A type which takes the values of languages and uses them as an union type here
}
Here two simple test cases:
const goodConfig: Configuration = {
languages: ['de-CH', 'en-GB'],
defaultLanguage: 'de-CH'
}
const badConfig: Configuration = {
languages: ['de-CH', 'en-GB'],
defaultLanguage: 'de-DE' // <- type error
}
Upvotes: 0
Views: 960
Reputation: 3593
There is another solution, quite simple and a short one. Define Languages as enum.
export enum Languages {
'de-CH',
'en-GB'
}
export type Language = keyof typeof Languages;
// Language as a key of enum, ie 'de-CH' | 'en-GB'
export type Configuration = {
languages: Language[],
defaultLanguage: Language
}
// Results in
export const goodConfig: Configuration = {
languages: ['de-CH', 'en-GB'],
defaultLanguage: 'de-CH'
}
export const badConfig: Configuration = {
languages: ['de-CH', 'en-GB'],
defaultLanguage: 'de-DE' // <- type error
}
And in case you require enum as array, you can easily convert it via following function
export function EnumKeys<T>(obj: T): Array<keyof T> {
return (Object.keys(obj) as Array<keyof T>).filter(value => isNaN(Number(value)) !== false);
}
Upvotes: 1
Reputation: 329773
You can't represent Configuration
as a specific type in TypeScript unless there's some moderately-sized union of possible language string literal types you want to select subsets from. if the languages
property can really accept any array of any possible strings, then a specific Configuration
type would have to an infinite union of all possibilities, which is not expressible in TypeScript.
--
Instead, you can represent Configuration<L>
as a generic type, like this:
type Configuration<L extends string> = {
languages: L[],
defaultLanguage: L;
}
The idea is that L
here represents a union of possible language string literals for a particular configuration object. The languages
property is an array of these, and the defaultLanguage
property is just one of these.
Conceptually, you'd like to say "a Configuration
is a Configuration<L>
object for some type L
I don't know or care about". This sort of generic type where you only care that the parameter is some type is called an existential type. Unfortunately, TypeScript does not directly support existential types (although they have been requested, see microsoft/TypeScript#14466). So there's no easy way to say type DesiredConfiguration = <∃L>Configuration L
right now.
That means in order to use Configuration<L>
, the L
type must be specified somehow. It can be done manually via annotation, but that is annoying:
const annoyingConfig: Configuration<'de-CH' | 'en-GB'> = {
languages: ['de-CH', 'en-GB'],
defaultLanguage: 'de-CH'
}
Blecch. Instead, we can try to get the compiler to infer the L
when faced with a candidate value of type Configuration<L>
. To do this we introduce a generic "do-nothing" or "identity" function which just returns its input at runtime, but the generic function call can be used to make the compiler infer L
:
const asConfiguration = <L extends string>(config: Configuration<L>) => config;
const goodConfig = asConfiguration({
languages: ['de-CH', 'en-GB'],
defaultLanguage: 'de-CH'
})
// const goodConfig: Configuration<"de-CH" | "en-GB">
Hooray, the inference worked. Now let's see the bad config error:
const badConfig = asConfiguration({
languages: ['de-CH', 'en-GB'],
defaultLanguage: 'de-DE' // wait, no error?
});
// const badConfig: Configuration<"de-CH" | "en-GB" | "de-DE">
Uh oh, there's no error. The inference worked too well, and now L
as the union of all three values!
Ideally, you want to say something like "when inferring a Configuration<L>
, use the languages
property only, and only check the defaultLanguage
property. We would like the L
in defaultLanguage
to be "non-inferential". This is the subject of a feature request, microsoft/TypeScript#14829. The idea is that we would change Configuration<L>
's definition to:
type Configuration<L extends string> = {
languages: L[],
defaultLanguage: NoInfer<L>;
}
for some suitable definition of NoInfer
. There's no official version of NoInfer
, and that GitHub issue is still open. But the discussion there does give a few suggestions for possible do-it-yourself implementations, such as this suggestion:
type NoInfer<T> = T & {}
or this suggestion:
type NoInfer<T> = [T][T extends any ? 0 : 1];
I usually go with the latter. If we use it, things behave as desired now. The goodConfig
is still good, and now badConfig
is bad:
const badConfig = asConfiguration({
languages: ['de-CH', 'en-GB'],
defaultLanguage: 'de-DE' // error!
// Type '"de-DE"' is not assignable to type '"de-CH" | "en-GB"'
});
// const badConfig: Configuration<"de-CH" | "en-GB">
This works now. One drawback to using a generic type instead of a specific type is that now anything in your code which you wanted to depend on Configuration
must also be generic now. Rather than spread this L
parameter all over your code base, you might want to keep it just so that it validates developer specified configuration objects, but widen to a non-constrained version for your own code.
So external-facing code will validate and then make calls to internal-facing code which will not care about that constraint:
function externalFacingCode<L extends string>(c: Configuration<L>) {
internalFacingCode(c);
}
interface InternalConfiguration {
languages: string[],
defaultLanguage: string
}
function internalFacingCode(c: InternalConfiguration) {
c.languages.find(x => x === c.defaultLanguage)!.toUpperCase();
}
Recap:
Configuration
as a specific type.Upvotes: 2