I want to access the value of a nested object in typescript using a generic function.
(TL;DR: version with link to playground at the end of the post).
Setup
Following setup. I want to access icons by their ligatures (CSS). Also I want to give all icons a symbolic name, so the ligature and the symbolic name of the same icon can differ, for example:
symbolic name: 'home'
ligature : 'icon-home'
Also it is possible to add custom icon fonts to extend the icon collection. Therefore the typescript have to be edited. To prevent name collisions and to make this expansion possible, I defined a namespace for icons called icon-source.
For example: Calling engine with icon-source="car" returns engine, calling engine with icon-source="airplaine" returns turbine.
My approach
So let's assume there are 3 defined icon sets, where icon-set="standard" is the default case.
At first I define an enum (string) which includes all available icon-sets.
const enum GlobalIconSources {
'standard' = 'standard',
'airplaine' = 'airplaine',
'car' = 'car',
}
Then I define for each icon-set another enum (string) and a corresponding type. The keys of the type are restricted to the strings of the enumeration.
const enum GlobalIconSourcesStandard {
'none' = 'none',
'home' = 'home',
'power' = 'power',
};
type ListOfStandardIcons = {
[key in keyof typeof GlobalIconSourcesStandard]: string
}
After that I defined an interface and a corresponding global object that include all icons.
/**
* Defines interface which includes all icon sources with their types.
*/
interface GlobalIcons {
[GlobalIconSources.standard]: ListOfStandardIcons,
[GlobalIconSources.airplaine]: ListOfSource1Icons,
[GlobalIconSources.car]: ListOfSource2Icons,
}
/**
* Defines global object in which all used icon names are defined.
* [symbolic-name] : [css-name/ligature]
*/
const ICONS : GlobalIcons = {
'standard': {
'none': '',
'home': 'icon-home',
'power': 'icon-power'
},
'airplaine': {
'wing': 'ap-wing',
'turbine': 'ap-turbine',
'landing-gear': 'ap-lg',
},
'car': {
'brakes': 'car-brakes',
'engine': 'car-engine',
'car-tire': 'car-tire',
},
};
Function for accessing values
Then there is the following function to access the values of the global object. The function should return the value (ligature) of the icon if the icon / icon-source combination exists. Otherwise the function returns undefined.
/**
* Returns the ligature of an icon.
* @param {map} Global icon object.
* @param {test} Symbolic icon name.
* @param {source} Source, where icon is defined.
* @returns Icon ligature when icon is defined, otherwise undefined.
*/
function getIcon<T extends GlobalIcons, K extends keyof GlobalIcons, S extends keyof T[K]>(map: T, test: unknown, source: Partial<keyof typeof GlobalIconSources> = GlobalIconSources.standard) : T[K] | undefined{
if(typeof test === 'string' && typeof source === 'string' && typeof map === 'object'){
if(map.hasOwnProperty(source)){
const subMap = map[source as K];
if(subMap.hasOwnProperty(test)) return subMap[test as S];
}
}
return undefined;
}
This works but:
The returned type of the function is ListOfStandardIcons | ListOfSource1Icons | ListOfSource2Icons | undefined (see ts playground). I expected string as returned type.
Let's assume I call the function with source="standard" and test=""home.
Then the generics should be:
T : GlobalIcons
T[K] : ListOfStandardIcons | ListOfSource1Icons | ListOfSource2Icons (assuming K is keyof T)
T[K][S] : string (assuming K is keyof T and S is keyof T[K]
I know that I am returning T[K] | undefined. I wanted to return T[K][S] | undefined but then the returned type is always undefined (according to TS playground).
Anyone an idea how I can handle this function that the returned type is the correct type of the subobject (ListOfStandardIcons | ListOfSource1Icons | ListOfSource2Icons)?
TS playground
Edit: Setup changed
I changed the setup and removed the enums and using objects now.
// Defines all available icon sources
const iconSources = {
'standard': 'standard',
'anotherSource': 'anotherSource',
} as const;
// List of icon sources corresponding to the iconSource object
type IconSource = Partial<keyof typeof iconSources>;
// Defines list icon of the "standard" icon source
const StandardIcons = {
'none': '',
'home': 'icon-home',
'power': 'icon-power',
} as const;
// Defines list icon of the "anotherSource" icon source
const Source1Icons = {
'car': 'my-car',
'airplaine': 'my-airplaine',
} as const;
// Defines interface and global object
interface Icons {
[iconSources.standard]: { [key in keyof typeof StandardIcons]: string },
[iconSources.anotherSource]: { [key in keyof typeof Source1Icons]: string },
}
// Access icon ligatures using icons[iconSourceName][iconKey]
const icons: Icons = {
'standard': StandardIcons,
'anotherSource': Source1Icons,
};
Also I changed the syntax for accessing the icon source. Now I want to pass 1 parameter which is "iconSource/iconName". When the string contains no /, the standard icon source is used. So instead of 2 parameter, 1 is needed now but this test parameter needs to have to type unknown because it is a user input which was not validated so far.
/**
* This function was copied from here: https://fettblog.eu/typescript-hasownproperty/
*/
function hasOwnProperty<X extends {}, Y extends PropertyKey>
(obj: X, prop: Y): obj is X & Record<Y, unknown> {
return obj.hasOwnProperty(prop)
}
function getIcon<L extends Icons, Source extends keyof L, Icon extends keyof L[Source]>(list: L, test: unknown): L[Source][Icon] | undefined {
if(typeof test === 'string'){
let icon: string = test;
let source: string = iconSources.standard; // Use the standard icon source, when no source is defined
if(test.indexOf('/') > -1){
const splitted = test.split('/');
source = splitted[0];
icon = splitted[1];
}
// If source is correct
if(hasOwnProperty(list, source)){
// If icon is correct return list[source][icon]
if(hasOwnProperty(list[source as Source], icon)) return list[source as Source][icon as Icon];
}
}
return undefined;
}
But i ran in the same problem that the function do always return the type undefined (the returned values are correct).
// Test
const input1: unknown = 'home';
const input2: unknown = 'standard/none';
const input3: unknown = 'anotherSource/car';
const input4: unknown = 'abc';
const input5: unknown = 'anotherSource/abc';
// Expected results but type of all variables is undefined
const result1 = getIcon(icons, input1); // => 'icon-home' with typeof string
const result2 = getIcon(icons, input2); // => '' with typeof string
const result3 = getIcon(icons, input3); // => 'my-car' with typeof string
const result4 = getIcon(icons, input4); // => undefined
const result5 = getIcon(icons, input5); // => undefined
GlobalIconSources.standardand never"standard"in code). If I keep enums then this might be what you want. If I remove enums then it simplifies to this. If either of those meet your needs I can write up an answer.Object.values(enum). But without a string enum, there would be also the index returned, but I need to preventObject.values(enum).includes(someVarDefinedWhileRuntime : unknown) == truewhensomeVarDefinedWhileRuntimecould be an index (0, 1, ... ).Object.values()with the code I changed it to. Please test that code against your use cases and get back to me if it works or does not work for you.