Generic classes in C# and Java can be thought of as class templates, which let us use placeholders instead of certain types. What they don't allow is having generic property names. Typescript does not have such limitation and easily lets us do that:
export type MakeProp<TKey extends string , TType> = {
[key in TKey]: TType
}
// MakeProp<'foo', number>
type equivalent1 = {
foo: number;
}
TS playground ↗
This does not look so impressive, after all, we could have written this type by hand just as easily. What we should know, however, is that we have crossed they type-key barrier ( not a real term :-) ) - we managed to make type into a key and create an object shape using only other types. This means, we can use our 'MakeProp' type within other generic types in Typescript. With mapped and conditional types, this means we can construct much more complex types with our mappings.
In a previous article we have looked at an ability to filter keys during mapping. This means, we've already crossed the type-key barrier before, only in an opposite direction - our keys became types.
// A way to get keyof only for certain types of keys
type KeyofMethods<TType> = ({
[key in keyof TType]: TType[key] extends Function
? key // notice, use of key instead of TType[key]
: never
})[keyof TType];
class FooBar {
a: number = 0;
c(){ return 0;};
d(){ return 0;};
}
type km = KeyofMethods<FooBar>; //"c"|"d"
TS playground ↗
This query mechanism is very powerful, since it allows us to pluck nested key / type information from existing types. Let's say we have a dictionary that maps human-readable geometric shapes to utility objects for them, which include 'kind'...
export const shapeUtil = {
square: {
kind: 'sqr',
is: (inst: { kind:string }): inst is Square => inst.kind === 'sqr',
},
circle: {
kind: 'cir',
is: (inst: { kind:string }): inst is Circle => inst.kind === 'cir',
}
}
...and we would like to get the type with code 'cir'.
type ValueWhereTSubKey<T, TSubKey, TTypeMatch>
= {
[key in keyof T]:
TSubKey extends keyof T[key]
? T[key][TSubKey] extends TTypeMatch
? T[key]
: never
: never;
}[keyof T];
// {
// kind: 'cir';
// is: (inst: { kind:string }): inst is Circle;
// }
type matchVal = ValueWhereTSubKey<typeof shapeUtil, 'kind', 'sqr'>;
TS playground ↗
With the examples above, we see that during mapping we can query deep into types of keys and we can map things conditionally. At the beginning of this article, we showed that simplest form of mapped types allows us to create very simple object shapes from key-type / value-type pairs. Below is an example of advanced transformation possible with this.
Notice use of as const to prohibit compiler from widening string literal type into string type.
type Before = {
square: {
kind: 'sqr' as const;
side: number;
},
circle: {
kind: 'cir' as const;
radius: number;
}
}
// {
// sqr: {
// kind: 'sqr';
// side: number;
// originalKey: 'square';
// },
// cir: {
// kind: 'cir';
// radius: number;
// originalKey: 'circle';
// }
type Rotated = RotateSubKey<Before, 'kind', 'originalKey'>;
type RotateSubKey:
export type MakeProp<TKey extends string , TType> = {
[key in TKey]: TType
}
/**
* Union type of all values of a given key.
* @example
* type Before = {
* square: {
* kind: 'sqr';
* side: number;
* },
* circle: {
* kind: 'cir';
* radius: number;
* }
* }
*
* // 'sqr' | 'cir'
* type AllValuesOfKind = AllValuesOfSubKey<Before, 'kind'>;
* */
type AllValuesOfSubKey<T, TSubKey> = {
[key in keyof T]: TSubKey extends keyof T[key]
? T[key][TSubKey]
: never;
}[keyof T];
/**
* Choose type where subkey matches certain type
* @example
* type Before = {
* square: {
* kind: 'sqr';
* side: number;
* },
* circle: {
* kind: 'cir';
* radius: number;
* }
* }
*
* // {
* // kind: 'cir';
* // radius: number;
* // }
* type matchVal = ValueWhereTSubKey<Before, 'kind', 'sqr'>;
* */
type ValueWhereTSubKey<T, TSubKey, TTypeToMatch> = {
[key in keyof T]:
TSubKey extends keyof T[key]
? T[key][TSubKey] extends TTypeToMatch
? T[key]
: never
: never;
}[keyof T];
/**
* Choose key where subkey matches certain type
* @example
* type Before = {
* square: {
* kind: 'sqr';
* side: number;
* },
* circle: {
* kind: 'cir';
* radius: number;
* }
* }
*
* // 'square'
* type matchKey = KeyWhereTSubKey<Before, 'kind', 'sqr'>;
* */
type KeyWhereTSubKey<T, TSubKey, TTypeToMatch> = {
[key in keyof T]:
TSubKey extends keyof T[key]
? T[key][TSubKey] extends TTypeToMatch
? key
: never
: never;
}[keyof T];
export type RotateSubKey<T,
TSubKey,
TOriginalKeyNewName extends string = never> = {
[key in AllValuesOfSubKey<T, TSubKey> & (string | number | symbol)]
: ValueWhereTSubKey<T, TSubKey, key>
& MakeProp<TOriginalKeyNewName, KeyWhereTSubKey<T, TSubKey, key>>;
}
TS playground ↗
So, how does this allow us to write more succinct code? Here is an example from an upcoming article about handling polymorphic types in the api. We make sure to avoid duplication as much as possible, instead using advanced mapping techniques to teach compiler about polymorphic types used.
Following file describes data that we receive from api. In particular, the different types of vehicles our company uses. Those types have a tag field __type, which contains a fully-qualified (including namespace) name of a C# or Java type that the api uses internally. At the end of the file we map types with their tags. We also add short names, which are used in our code to simplify working with those types (mostly identical to type names in TS code).
// type information from api
const __type_CargoTruck = 'CargoTruck#OurCorp.Vehicles';
const __type_Sedan = 'Sedan#OurCorp.Vehicles';
//...more tags
export type VehicleBaseProps = {
licensePlate: string,
model: string,
engineNumber: string,
}
export type Sedan = VehicleBaseProps & {
__type: typeof __type_Sedan;
}
export type CargoTruck = VehicleBaseProps & {
__type: typeof __type_CargoTruck;
maxCargoWeightKg: number;
}
// lots more types
// now we map the types and prepare util for them in a generic manner
// in this dictionary we link '__type' tags with Types we have defined
// and their short names
const vehicleTypesMap = {
[__type_CargoTruck]: isTagFor<CargoTruck>()
.useShortName('CargoTruck' as const),
[__type_Sedan]: isTagFor<Sedan>()
.useShortName('Sedan' as const),
//...more mappings
} as const;
// get ultimate supertype of all vehicles, equivalent to CargoTruck | Sedan | ...
export type Vehicle = AllMappedTypes<typeof vehicleTypesMap>;
// get a dictionary with utils for our types, using short names to access them
export const vehicleTagUtil = getShortNameTagUtilDict(vehicleTypesMap, '__type');
// notice the use of short name 'Sedan'
const sedanInstance: Sedan = vehicleTagUtil.Sedan.getBlank();
Here are the types used to make the map.
export type TypeInfoWithShortName<TType, TShortName extends string> = {
__type?: TType;
shortName: TShortName
}
export type TypeInfo<TType> = {
__type?: TType;
useShortName<TShortName extends string>(shortName: TShortName):
TypeInfoWithShortName<TType, TShortName>;
}
export const isTagFor = <TType>(): TypeInfo<TType> =>
({
useShortName: <TShortName extends string>
(shortName: TShortName):
TypeInfoWithShortName<TType, TShortName> => ({
shortName: shortName
})
});
And the types used to transform the initial map into various derived forms to be used in our code. This file may be easier to read on github.
import { AllValuesOfSubKey, KeyWhereTSubKey } from 'ts-typing-util';
// adapt 'AllValuesOfSubKey' type to avoid having to specify '__type' every time
export type AllMappedTypes<T> = Exclude<AllValuesOfSubKey<T, '__type'>, undefined>;
// since our types are not classes, we provide a utility type to work with them:
// get blank instances with correct '__type' and type-guard incoming api data
export type TagUtil<TPolymorphic, TConcrete extends TPolymorphic, TTag extends string> = {
tag: TTag,
typeGuard: (inst: TPolymorphic) => inst is TConcrete,
getBlank: () => TConcrete
}
export const getTagUitlFactoryFor =
<TPolymorphic, TTagKey extends keyof TPolymorphic>(tagKey: TTagKey) =>
<TConcrete extends TPolymorphic, TTag extends string>(tagValue: TTag): TagUtil<TPolymorphic, TConcrete, TTag> => {
return {
tag: tagValue,
typeGuard: (inst: TPolymorphic): inst is TConcrete => (inst as any)[tagKey] === tagValue,
getBlank: () => {
const newInst: TConcrete = {} as any;
(newInst as any)[tagKey] = tagValue;
return newInst;
}
}
}
// shape of initial dict mapping __type tags to TS types and convenience short names
type TagUtilDict<T extends { [typeTag: string]: { __type?: unknown } }> = {
[key in keyof T & string]:
T[key] extends TypeInfoWithShortName<infer TType, infer _TShortName> ? TagUtil<AllMappedTypes<T>, AllMappedTypes<T> & TType, key> :
T[key] extends TypeInfo<infer TType> ? TagUtil<AllMappedTypes<T>, AllMappedTypes<T> & TType, key>
: never;
}
type TypeTagToShortNameDict = {
[typeTag: string]: {
shortName: string,
__type?: unknown
}
};
export type ShortNameUtilDict<T extends TypeTagToShortNameDict> = {
[key in AllValuesOfSubKey<T, 'shortName'>]:
TagUtilDict<T>[keyof T & string & KeyWhereTSubKey<T, 'shortName', key>];
}
// function that turns type map into a dictionary of type utils
export function getShortNameTagUtilDict<T extends TypeTagToShortNameDict,
TTagPropName extends keyof AllMappedTypes<T>>(typesMap: T, tagPropName: TTagPropName): ShortNameUtilDict<T> {
const factory = getTagUitlFactoryFor<AllMappedTypes<T>, TTagPropName>(tagPropName);
return Object.keys(typesMap).reduce((cur, key) => {
const map = typesMap[key];
(cur as any)[map.shortName] = factory(key);
return cur;
}, {} as ShortNameUtilDict<T>);
}
Summary
Examples in the article demonstrated, how we can reshape our types with more than just symetrical mappings. With a bit of conventions about shape of our types and names of our properties, this allows us to describe complex mapping function in such a way, where end-dev will never have to resort to explicitly specifying types in generics, no type information will be lost during mapping and end-developers code will be very succinct.