Recent articles About

Compiling enterprise

Ivan Koshelev blog on software development

Pragmatic uses of Typescript type system 02 Mapped types [2019 May 15] Typescript, types, type mapping

If you keep track of TypeScript innovations, you've probably used a Mapped Type like Partial, and maybe even pressed “Go to definition” to see, how it is defined under the hood.

type Partial<T> = {
    [P in keyof T]?: T[P];
}

Sadly, while many people are using predefined Mapped Types, few have adapted writing their own to utilize the full power of Typescript. Even fewer still realize the full extent of their power in types like the following:

// Readonly that is applied recursively 
// to properties which are objects, instead of just first level
type ReadonlyDeep<TType> = {
    readonly [key in keyof TType]: TType extends Object 
                                        ? ReadonlyDeep<TType[key]> 
                                        : TType[key];
}

let t = {b: 1, c: {d:2}}

let readonly: Readonly<typeof t> = t;
readonly.c.d = 5; // :-( no error

let readonlyDeep: ReadonlyDeep<typeof t> = t;
readonlyDeep.c.d = 5; // :-) error!


// A way to get keoyf 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" 


// A way to prohibit access to anything that is not a function,
// to enforce method usage in part of code, while having relaxed rules elsewhere
type MethodsOf<TType> = Pick<TType, KeyofMethods<TType>>

const m = <MethodsOf<FooBar>> new FooBar();
let c: number = m.c(); // fine
let a = m.a;    //error!
TS playground

Lets explore the practical application and peek into still more fantastic possibilities.

In order to understand syntax presented in this article, it is a good idea to make sure you understand the previous one from the TS types series .

Before Mapped Types: Index types

It is likely you had to define an Index type, commonly referred to as dictionary type at-least a few times.

type Dict = {
    [key: string] : number; // this is called in 'Indexer'
}

This simplistic definition tells TS compiler that our type can have any keys, so long as each key is a string, and values contained in those keys will be of type number. There is not much more you can do with this form. You could make the type of prop generic and you could limit the set of possible keys from 'every possible string' to 'every possible string that can be parsed to a valid number'.

type Dict<TProp> = {
    [key: number] : TProp; 
}
Side note on indexers. They can only be of type string or number in Typescript. Internally, though, they are always strings and we can only choose 'number' as type here because of the ubiquity of this case in JS. Nevertheless, remember, that a[5] is equivalent to a[(5).toString()] or a['5'].

Mapped type basics

Lets looks a very simplistic Mapped type.

type Mapped = {
    [key in 'a' | 'b'] : number;
}
// Above reads as “for each key in set of ['a','b'], create a 
// property with type number”, equivalent to
type Mapped = {
    a: number;
    b: number;
}

Compared to our `Dict` type, the only thing that changed visually is goring from [key: string] to [key in 'a' | 'b']. But that is just a very simplistic case, where we use constants for both “'a' | 'b'” and “number”. In reality, both of them can be a lot more complex, including Conditional types.

Here and below, I urge to use Typescript playground to experiment with provided code and verify your understanding as you go.
type Mapped = {
    [key in /*expression to get a set of keys; 
            it can be anything that resolves 
            to a String literal union */    ] : /*expression to get 
                                                type for a each key*/;
}

Let's see, what is possible with the expression inside the square brackets.

class FooBar {
    a = 0;
    b = '';
    c(){return 0;}
}

type Mapped1 = {
    //'a'|'b'|'c'
    [key in keyof FooBar]: number; 
}

type Mapped2<TType> = {
     //one of the most practical forms
    [key in keyof TType]: number; 
}

type Mapped3 = {
    //'a'|'b'|'c'|'d'
    [key in keyof FooBar | 'd']: number; 
}

type keys = keyof FooBar | 'd';

type Mapped4 = {
    //same as above
    [key in keys]: number; 
}

type Mapped5 = {
    //remove the 'd' that we added
    [key in Exclude<keys,'d'>]: number; 
}

type Mapped6<TType> = {
    // use keys, if object, otherwise just one key 'a'
    [key in (TType extends Object ? keyof TType : 'a')]: number;
}
TS playground

Important part is that we have limited the set of all possible keys from an infinite set of 'any string' to a finite set of string literals, aka String literal union . Going from very broad possibility to a very narrow one is good for compiler, especially going from infinite to finite – the fewer cases we have, the better it can reason about each case. For Mapped Types, having a finite set allows the compiler to process each key during compilation to determine its corresponding type. In the type part, you can use conditional types once again and this time you are not limited to a string literal union.

class FooBar {
    a = 0;
    b = '';
}
    
type Mapped<TType> = {
   [key in keyof TType]: /*in here you have access to 'key', TType,
                        and any other type from lexical scope */; 
}

type Mapped1<TType> = {
    // simple - for each key, return its name as string literal type
   [key in keyof TType]: key; 
}
let a: Mapped1<FooBar> = {
    a: 'a',
    b: 'b'
}

type Mapped2<TType> = {
    // most often used, maintain type from TType[key]
   [key in keyof TType]: TType[key]; 
}
let b: Mapped2<FooBar> = {
    a: 0,
    b: ''
}

type Mapped3<TType> = {
    // advanced  - conditional; map numbers to undefined
   [key in keyof TType]: TType[key] extends number
                                        ? undefined
                                        : TType[key]; 
}
let c: Mapped3<FooBar> = {
    a: undefined,  
    b: 'b'
}
TS playground

As you remember from the previous article, conditional types, among other things, make TS type system powerful to the point of Turing completeness. Very agile mappings are possible.

class FooBar {
    a = 0;
    b = '';
    c(){return 0;}
}

type Mapped1<TType> = {
    // remove numbers and functions that return numbers
   [key in keyof TType]:
            TType[key] extends (...args: unknown[]) => number ? undefined :
            TType[key] extends number ? undefined : 
            // this is the 'catch-all' value, but we could go on
            TType[key]; 
}

let a: Mapped1<FooBar> = {
    a: undefined,
    b: 'b',
    c: undefined
}
TS playground

In the code above, you may have noticed a special type unknown. We will also use another special type of never. If you are interested in the theory behind them, here is a good article. For simplicity, you can think of them as follows.

unknown is like any, but type safer: anything can be assigned to unknown, since unknown has no known constraints (like any). But unknown can't be assigned to anything but another unknown or any without explicit type-safety checks / casts.

unknown is often used with functions and Inferrence to express 'whatever arguments', as in above example with (...args: unknown[])

Nothing can be assigned to a variable of type never, except another never and this type is auto-removed from any unions. A function returning type never must always end in a thrown exception, thus never returning normal result. In pratical terms, you can think of never type as 'do not interact with this!' sign. In mapped types, never allows us to remove unwanted members from resulting type, a lot more explicitly than undefined (since undefined is a legit type in JS).

Lets revisit our examples from the beginning of the article.

 // A way to get keoyf 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" 

Lets go step by step.

 // step 1: map all props with non-functions type
// to have type 'never', and the rest 
// to have a type of string literal of the key itself
type Methods<TType> = ({
    [key in keyof TType]: TType[key] extends Function
                            ? key // notice, use of key instead of TType[key]
                            : never
});

type step1Result = {
    a: never,
    c: 'c',
    d: 'd'
}

// step2: apply Lookup Type to step1Result.
// This will produce a union of type
// of every property (of which all are either stirng literals or 'never').
type step2Result = step1Result['a'|'c'|'d'];

type finalUnion = never | 'c' | 'd';

// step 3: 'never' is auto-excluded from all unions
type finalResult = 'c' | 'd';

Finally, look at how Pick works.

type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

It may be counterintuitive, how extends works with string literal unions. Normally, you expect extending type to have more properties than the type being extended. But here, the opposite is true:

class FooBar {
    a: number = 0;
    c(){ return 0;};
    d(){ return 0;};
}
type b =  Pick<FooBar,'a' | 'c'>;
// if we write this 'extends' in terms of string literal unions, we will get
// 'a' | 'c' extends 'a' | 'c' | 'd'

// In this case, you should draw parallels between set theory and type theory. 

//A subclass 	extends 	a super class. 
//A subset 	is contained 	within a superset.

// There will always be >= instances of a superclass compared to subclass,
// And the superset contains >= members compared to subset

Practical demo

For a practical demo of the combined techniques we used, here is a type that maps what happens to a type if it is serialized to json and back. This is just POC, written in 5 minutes and not tested much, take it as demo only.

type KeysJsoned<TType> = ({
    // functions are not serialized
    [key in keyof TType] : TType[key] extends Function ? never : key;
})[keyof TType];

type MappingsJsoned<TType> = {
                            // dates become strings 
    [key in keyof TType] : TType[key] extends Date ? string :
                            // regexs become empty object
                           TType[key] extends RegExp ? {} :
                           TType[key] extends number ? TType[key] :
                           TType[key] extends string ? TType[key] :
                           TType[key] extends boolean ? TType[key] :
                           // objects and arrays Jsoned recursively
                           JsonOf<TType[key]> ;
}

type JsonOf<TType> = MappingsJsoned<Pick<TType, KeysJsoned<TType>>>

class Test {
    a = 1;
    b = '';
    c = true;
    d(){};
    e = new Date();
    f = {
        g: true,
        h(){},
        i: new Date()
    };
    j = new RegExp('abc');
    k = [{
        l: true,
        m(){},
        n: new Date()
    }]
}

function toJsonAndBack<TType>(val: TType): JsonOf<TType>{
    return JSON.parse(JSON.stringify(val));
}

let r: JsonOf<Test> = toJsonAndBack(new Test());


// resulting type
type tr = {
    a: number;
    b: string;
    c: boolean;
    e: string;
    f: {
        g: boolean;
        i: string;
    };
    j: {};
    k: Array<{ l: boolean;
               n: string;
               }>
        }
}

TS playground

A selection of more robust implementations for some of the concepts seen in the article and more: ts-essentials on github

Beyond practical demo

If you use mapped types, conditional types, union types, etc. together, very interesting things are possible in TS. To get the imagination going, here is some code that splits a type into parts, transforms them individually and then combines it back with a union type, all to produce a type where properties with typeof === 'object' are readonly, but the rest is not.


// Make only properties of certain type readonly!

// Get all keys for properties that extend certain type
type KeysofExtending<TType, TExtendsType> = ({
    [key in keyof TType]: TType[key] extends TExtendsType 
                            ? key
                            : never
})[keyof TType];

// Get all keys for properties that DONT extend certain type
type KeysofNotExtending<TType, TExtendsType> = ({
    [key in keyof TType]: TType[key] extends TExtendsType 
                            ? never
                            : key
})[keyof TType];

type ReadonlyForNonprimitiveProps<TType> = 
        Pick<Readonly<TType>, KeysofExtending<TType, object>>
        & Pick<TType, KeysofNotExtending<TType, object>>;

// you can read above code as 'pick properties of types extending
// object from readonly version of TType, pick properties of
// types NOT extending object from normal version of TType and union
// these two picks.'

class Test1 {
    a = {}
    b = 5;
}

let m2 = <ReadonlyForNonprimitiveProps<Test1>>new Test1();
m2.a = {}   // Error, readonly!
m2.b = 7;   // No error, fine!
TS playground

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