Recent articles About

Compiling enterprise

Ivan Koshelev blog on software development

Advanced uses of Typescript type system 01 From mapped types to compile-time type construction [2019 July 21] Typescript, types, type mapping This article continues exploration of typescript type system applications. Previous articles on pragmatic uses of ad-hoc typing and mapped types showcased type system usages for application developers. This one will go a bit deeper, and is more useful for library developers. Make sure you fully understand concepts explained previously.

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.


Ivan Koshelev photo

I'm passionate for anything that can be programed and automated to make life better for all of us.

Archives

  1. January 2023 (1)
  2. January 2022 (2)
  3. November 2021 (1)
  4. May 2021 (1)
  5. March 2020 (1)
  6. August 2019 (1)
  7. July 2019 (1)
  8. May 2019 (1)
  9. February 2019 (1)
  10. October 2017 (1)
  11. August 2017 (3)
  12. July 2017 (3)
  13. May 2017 (3)

Elsewhere

  1. GitHub@IKoshelev
  2. LinkedIn
  3. NuGet@IKoshelev
  4. NPM@IKoshelev