Robin
Robin

Reputation: 8508

Type definition for an object, which takes (self) reference on its values for a type

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

Answers (2)

Timothy
Timothy

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

jcalz
jcalz

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:

  • You can't currently represent Configuration as a specific type.
  • You can represent it as a generic type, but you need to specify its type parameter.
  • It is tedious to do this manually via annotation.
  • It is easier to do via helper function but setting up the function is tricky.
  • In any case it's more complicated than a specific type so you might want to limit its use to just validation code.

Playground link to code

Upvotes: 2

Related Questions