Exploring the power of type systems in TypeScript with type manipulation

Exploring the power of type systems in TypeScript with type manipulation

·

10 min read

In addition to offering a variety of useful utility types, TypeScript also provides several operators and special types that enable us to flexibly create types from types. In this article, we will explore these utilities and learn how to combine them to manipulate types efficiently.

1. Generics

If you’ve code C# or Java, the term “generics” should be familiar to you. Generics allows us to create reusable classes or functions that can work with different types rather than restricted to a single type. Similarly, in TypeScript, we can create generic types for reuse.

Let’s consider an example where we want do define a function fn accepts a parameter and returns a value. The constraint is that the return value must have the same type as the parameter, and the parameter can be of any type, such as number, string, boolean, etc.

Initially, we can think of using a union type, but it does not work as expected because the return type would still be a union type. This means we wouldn’t know the exact type of the return value. Then, we might consider using any. While any is a form of generic type, it isn’t type-safe because we lose the information of the actual type passed to the function.

const Type = string | number | boolean;
function fn(arg: Type): Type {
    return arg;
}

And thanks to the generic mechanism, we can use something called a type variable, which can be named anything, although it’s conventionally referred as Type or T for brevity. This type variable captures the actual type provided when the function is called:

function fn<Type>(arg: Type): Type {
    return arg;
}
const A = fn<number>(2);
// const A: number;
const B = fn<string>(2);
// const A: string;

That is how we use generic type: we use a type variable to capture the actual type and reuse it when declaring types.

Sometimes, we do not want the generic Type to accept just any type passed to our function. In such cases, we can add constraints to the generic Type. For instance, we want our function to accept only an array type, we can do this:

function fn<Type extends Array<any>>(arg: Type): Type {
    console.log(arg.length)
    return arg;
};

Note that the extends keyword is used to add constraints to our generic types.

2. Keyof Type Operator

The keyof operator is used to create a union type of string or number literal where each literal type corresponds to a key in an object type. Let’s take a look at this example:

type Person = {
    name: string;
    age: number
};
type Info = keyof P;
// type Info = "name" | "age"

3. Typeof Type Operator

Don’t confuse the typeof keyword in TypeScript with the one in JavaScript, even though they perform similar tasks- both return a type. But in JavaScript the typeof operator returns a type in expression context:

console.log(typeof 'Hello World') // string
console.log(typeof 123) // number
console.log(typeof {name: 'Alice'}) // object

function fn() {}
console.log(typeof fn} // function

The typeof keyword in TypeScript works in type context:

const a = 123;
type A = typeof a; 
// type A = number

const b = 'Hello';
type B = typeof b;
// type B = string

const o = {name: 'Alice'}
type O = typeof o;
// type O = { name: string } 

function fn() {}
type F = typeof F;
// type F = () => void

4. Indexed Access Types

Consider objects and arrays in JavaScript. We can access the value of a specific item using a key for objects or an index for arrays.

const obj = {
    name: 'Alice'
}
const name = obj['name']; // Alice

const arr = ['Apple','Orange']; // Apple
const apple = arr[0];

Similar to JavaScript, we can access the type of a specific property in an object or array type by using a key or an index, respectively. This type is called indexed access type.

type Person = {
    name: string;
}
type Name = Person['name'];
// type Name = string;

We can use unions or keyof operator to create union types:

type Person = { name: string; age: number }
type IP = Person['name' | 'age'];
// or
type IP = Person[keyof Person]
// type IP = string | number

For array type, we can use number to get the type of an array’s element:

const strArr = ['apple','orange'];
type S = typeof strArr[number];
// type S = string;

const mixArr = [1,'apple'];
type M = typeof mixArr[number];
// type M = number | string;

const objArr = [{name: 'Alice', age: 20}];
type O = typeof objArr[number];
// type O = {name: string; age: number}

5. Conditional Types

When working with plain JavaScript, we are familiar with conditional expressions:

const num = 3;
const numType = num%2 === 0 ? 'even' : 'odd';

In Typescript, conditional types follow a similar pattern to JavaScript’s conditional expressions. Based on the input type, we return the output type with a specific condition. The general formula is as follows:

SomeType extends OtherType ? TrueType : FalseType;

And you can see, the condition determines if an input type (SomeType) is assignable to a specific type (OtherType) using the extends keyword. To clarify, SomeType is assignable to OtherType if all required properties of OtherType appear in SomeType with the correct structure and type values—even if we don’t explicitly use the extends keyword or the & operator to declare our types. Let’s take a look these examples below:

  • extends with interface

        interface Vehicle {
            brand: string;
            wheels: number;
        }
        interface Car extends Vehicle {
            isDieselEngine: boolean;
        }
    
        type TrueType = Car extends Vehicle ? number : string;
        // type TrueType = number
    

    & with type

        type Person = {
            name: string;
        }
        type Student = {
            grade: number
        } & Person;
    
        type TrueType = Student extends Person ? string : any;
        // type TrueType = string
    
  • Without extends or &

    
        type Student = {
            name: string;
        }
        type Teacher = {
            name: string;
            salary: number;
            department: string;
        }
        type Staff = {
            name: string;
            salary: number;
        }
    
        type TrueType = Teacher extends Staff ? string : any;
        // type TrueType = string
        type FalseType = Student extends Staff ? string : any;
        // type FlaseType = any
    

These examples above help us understand conditional types, but they may seem impractical at first glance. However, conditional types become more powerful when combined with generics:

type IsArray<T> = T extends any[] ? true : false;

// Examples:
type Test1 = IsArray<number[]>; // Result: true
type Test2 = IsArray<string[]>; // Result: true
type Test3 = IsArray<number>;   // Result: false
type Test4 = IsArray<{ key: string }>; // Result: false

Another feature that makes conditional types even more powerful is the usage of theinfer keyword. We use this keyword to create a new generic type and compare it in condition scope (SomeType extends OtherType). We can use the new generic type in return type of conditional type. Let’s see an example:

type ElementType<T> =  T extends Array<infer E> ? E : T;

// Examples:
type Test1 = ElementType<number[]>; // Result: number
type Test2 = ElementType<string[]>; // Result: string
type Test3 = ElementType<boolean>;  // Result: boolean (not an array, so type stays the same)
type Test4 = ElementType<[number, string]>; // Result: number | string

6. Mapped Types

Mapped types are used when we want to declare the type of properties of an object types without explicitly declaring each property and its type. Instead, we use shorthand syntax like this:

type ScoreMap = {
    [key: string]: number;
}
const Score: ScoreMap = {
    A: 10,     
    B: 8,
    C: 6
}

Mapped types are particularly useful when used with a union of property keys (often created with the keyof operator) when we want to create a new type based on an existing type:

type Book = {
    name: () => string;
    description: () => string
}

type BookInfo = {
    [key in keyof Book]: string;
};
// Equivalent to
type BookInfo = {
    name: string;
    description: string
}

One interesting feature of mapped types is the as clause, which allows us to re-map keys:

type MappedType<Type> = {
    [key in keyof Type as NewKey]: Type[key]
}

It might seem nonsensical when create NewKey but not use it, right? This feature become more useful when combined with template literal types (as we will see this in the next section). We can create a new property name base on as clause, like this:

type BookInfo = {
    name: string;
    description: string;
}

type Book = {
    [key in keyof BookInfo as `get${Capitalize<key & string>}`]:() => BookInfo[key]
 }

// Equivalent to
type Book = {
    getName: () => string;
    getDescription: () => string;
}

Or, in more generic way:

type MappedType<Type> = {
      [key in keyof Type as `get${key}`]:() => Type[key]
}

7. Template Literal types

Before diving into template literal types, let’s first understand string literal types. String literal types represent specific string values rather than the general string type:

type Mode = 'ON' | 'OFF';

Template literal types are types created using template string syntax, similar to JavaScript, but instead of accepting string values, they accept string literal types. For example:

type Mode = 'ON';
type EngineOn = `Engine${Mode}`;
// type EngineOn = 'EngineOn'

By combining template literal types with union types, we can create a set of all possible string literal types:

type Mode = 'ON' | 'OFF';
type EngineMode = `Engine${Mode}`;
// type EngineMode = 'EngineOn' | 'EngineOff'

When working with template literal types we might need some utility string types provided by Typescript such as: UpperCase<Type> andLowerCase<Type>. For more details, you can checkout the article: “Common utility types in Typescript and their usages”.

8. Example of Combining Various Types

So far, we’ve learned about basic utility types and special operators. Let’s dive into an example where we will combine all of them and see how powerful the TypeScript type system is. Snake case and camel case are among the most popular naming conventions, often depending on the language being used. For instance, backend developers using languages like Ruby often prefer snake case, while frontend developers working with JavaScipt usually adopt camel case.

Backend Data Format

Here’s an example how data might be structured and returned from a backend service:

const BackendData = {
    name: 'Sakura';
    role_title: 'UI/UX Designer';
    address: {
        nation: 'Japan';
        city_zip_code: 72000
    }
};

Frontend Data Format

Howerver, on the frontend, developers prefer camel case for consistency with JavaScript convention:

const FrontendData = {
    name: 'Sakura';
    roleTitle: 'UI/UX Designer';
    address: {
        nation: 'Japan';
        cityZipXCode:  72000
    }
};

The problem

To define these structures in TypeScript, we could declare 2 separate types:

type SnakeCaseData = {
    name: string;
    role_title: string;
    address: {
        nation: string;
        city_zip_code: number
    }
};

type CamelCaseData = {
    name: string;
    roleTitle: string;
    address: {
        nation: string;
        cityZipXCode: number
    }
};

However, this approached is repetitive and violate the DRY (Don’t Repeat Yourself) principle. Instead, we can leverage TypeScipt’s utility types to create a reusable type that automatically converts a snake case to its camel case type equivalent.

// Convert a snake_case string to camelCase
type CamelCaseString<S> = S extends `${infer A}_${infer B}` 
                            ? `${A}${Capitalize<CamelCaseString<B>>}` 
                            : S;

type CamelCaseObject<T extends object> = {
      [key in keyof T as `${CamelCaseString<key>}`] : T[key] extends object 
         ? CamelCaseObject<T[key]> 
         : T[key]
};

// Example input type
type Data = {
    name: string;
    role_title: string;
    address: {
        nation: string;
        city_zip_code: number
    }
};

// Automatically generate the camelCase equivalent type
type CamelCaseData = CamelCaseObject<Data>;

// Equivalent to:
type CamelCaseData = {
    name: string;
    roleTitle: string;
    address: {
        nation: string;
        cityZipXCode: number
    }
};

By combining generic types, conditional types (with infer), mapped types and the keyof operator we created a reusable utility type, CamelCaseObject<Type>. This approach eliminates redundancy, ensuring the consistence between related types, and making the codebase cleaner and easier to maintain.

Isn’t it fascinating? TypeScript’s type system allows us to write code that is both powerful and concise, significantly improving developer experience.