DEV Community

Cover image for 15 Advanced TypeScript Tips and Tricks You Might Not Know πŸ€”πŸ’‘
Matt Lewandowski
Matt Lewandowski

Posted on

15 Advanced TypeScript Tips and Tricks You Might Not Know πŸ€”πŸ’‘

TypeScript has become essential for many developers, offering type safety and an enhanced developer experience. While most are familiar with its basic features, TypeScript has a lot of advanced techniques that can increase the type safety of your application. This article dives into 15 lesser-known TypeScript tips and tricks that will expand your toolkit and potentially reshape how you approach TypeScript development. Without wasting any time, let's get started!

1. String Literal Interpolation Types

String literal types are powerful, but did you know you can interpolate them? This feature allows for dynamic creation of string literal types based on other types.

type EventName<T extends string> = `${T}Changed`;
type UserEvent = EventName<"user">; // type UserEvent = "userChanged"
Enter fullscreen mode Exit fullscreen mode

This technique is particularly useful when working with event systems or creating consistent naming conventions across your codebase. For instance, you could use it to automatically generate getter names:

type Getter<T extends string> = `get${Capitalize<T>}`;
type UserGetter = Getter<"username">; // type UserGetter = "getUsername"
Enter fullscreen mode Exit fullscreen mode

2. Branded Types Using Intersections

Branded types provide a way to create nominal typing in TypeScript's structural type system. They're excellent for preventing type mixing when you have multiple string or number types that shouldn't be interchangeable.

type UserId = string & { readonly brand: unique symbol };
type PostId = string & { readonly brand: unique symbol };

function createUserId(id: string): UserId {
    return id as UserId;
}

function createPostId(id: string): PostId {
    return id as PostId;
}

const userId = createUserId("user123");
const postId = createPostId("post456");

// This will cause a type error:
// const error = userId = postId;
Enter fullscreen mode Exit fullscreen mode

This pattern ensures that even though UserId and PostId are both strings under the hood, they can't be accidentally mixed in your code.

3. Conditional Types with Infer

The infer keyword in conditional types allows you to extract type information from complex types. It's particularly useful when working with functions, promises, or arrays.

type UnpackPromise<T> = T extends Promise<infer U> ? U : T;

type ResolvedType = UnpackPromise<Promise<string>>; // type ResolvedType = string
type NonPromiseType = UnpackPromise<number>; // type NonPromiseType = number

// Another practical example: extracting return types of functions
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function fetchUser() { return { id: 1, name: "John" }; }
type User = ReturnType<typeof fetchUser>; // type User = { id: number; name: string; }
Enter fullscreen mode Exit fullscreen mode

This technique allows for powerful type inference and manipulation, enabling you to create more flexible and reusable type definitions.

4. Template Literal Types

Template literal types combine literal types and string manipulation to create powerful string-based type constraints.

type ColorVariant = "light" | "dark";
type Color = "red" | "green" | "blue";
type Theme = `${ColorVariant}-${Color}`;

// Theme is now equivalent to:
// "light-red" | "light-green" | "light-blue" | "dark-red" | "dark-green" | "dark-blue"

function setTheme(theme: Theme) {
    // Implementation
}

setTheme("light-red"); // OK
// setTheme("medium-purple"); // Error: Argument of type '"medium-purple"' is not assignable to parameter of type 'Theme'.
Enter fullscreen mode Exit fullscreen mode

This feature shines when working with CSS-in-JS libraries, API route definitions, or any scenario where you need to enforce specific string patterns at the type level.

5. Recursive Type Aliases

Recursive type aliases allow you to define types that refer to themselves. This is particularly useful when working with tree-like structures or nested data.

type JSONValue = 
    | string 
    | number 
    | boolean 
    | null 
    | JSONValue[] 
    | { [key: string]: JSONValue };

const data: JSONValue = {
    name: "John Doe",
    age: 30,
    isStudent: false,
    hobbies: ["reading", "cycling"],
    address: {
        street: "123 Main St",
        city: "Anytown",
        coordinates: [40.7128, -74.0060]
    }
};
Enter fullscreen mode Exit fullscreen mode

This JSONValue type accurately represents any valid JSON structure, no matter how deeply nested. It's invaluable when working with APIs, configuration files, or any scenario involving complex, nested data structures.

These first five tips scratch the surface of TypeScript's advanced features. They demonstrate how TypeScript can provide strong typing even in complex scenarios, enhancing code reliability and developer productivity. In the next section, we'll explore more advanced concepts that push the boundaries of TypeScript's type system.

6. Variadic Tuple Types

Variadic tuple types, introduced in TypeScript 4.0, allow for more flexible tuple manipulations. They're particularly useful when working with functions that take a variable number of arguments or when you need to combine tuples dynamically.

type Concat<T extends unknown[], U extends unknown[]> = [...T, ...U];
type Result = Concat<[1, 2], [3, 4]>; // type Result = [1, 2, 3, 4]

function concat<T extends unknown[], U extends unknown[]>(arr1: T, arr2: U): Concat<T, U> {
    return [...arr1, ...arr2];
}

const result = concat([1, 2], [3, 4]); // result: [1, 2, 3, 4]
Enter fullscreen mode Exit fullscreen mode

This feature enables type-safe operations on tuples, which can be invaluable when working with APIs that return or expect specific tuple structures.

7. Key Remapping via 'as'

The as clause in mapped types allows you to transform the keys of an object type. This can be incredibly useful for creating derived types with modified property names.

type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};

interface Person {
    name: string;
    age: number;
}

type PersonGetters = Getters<Person>;
// Equivalent to:
// {
//     getName: () => string;
//     getAge: () => number;
// }

const person: Person = { name: "Alice", age: 30 };
const getters: PersonGetters = {
    getName: () => person.name,
    getAge: () => person.age
};

console.log(getters.getName()); // Output: "Alice"
Enter fullscreen mode Exit fullscreen mode

This technique is particularly useful when generating derived types for frameworks or libraries that expect specific naming conventions.

8. Const Assertions in Type Positions

Const assertions can be used to create more specific literal types from arrays and objects. This is especially useful when you want to use runtime values as types.

const colors = ["red", "green", "blue"] as const;
type Color = typeof colors[number]; // type Color = "red" | "green" | "blue"

function paintShape(color: Color) {
    // Implementation
}

paintShape("red"); // OK
// paintShape("yellow"); // Error: Argument of type '"yellow"' is not assignable to parameter of type 'Color'.

// Another example with an object
const config = {
    endpoint: "https://api.example.com",
    timeout: 3000
} as const;

type Config = typeof config;
// Equivalent to:
// {
//     readonly endpoint: "https://api.example.com";
//     readonly timeout: 3000;
// }
Enter fullscreen mode Exit fullscreen mode

This feature allows you to maintain a single source of truth for both runtime values and type information, reducing the chance of inconsistencies between your types and actual data.

9. Discriminated Unions with 'never'

Discriminated unions are a powerful way to model mutually exclusive states. Combined with the never type, they can provide exhaustive pattern matching and improved type safety.

type Shape = 
    | { kind: "circle"; radius: number }
    | { kind: "square"; sideLength: number }
    | { kind: "triangle"; base: number; height: number };

function area(shape: Shape): number {
    switch (shape.kind) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "square":
            return shape.sideLength ** 2;
        case "triangle":
            return 0.5 * shape.base * shape.height;
        default:
            const _exhaustiveCheck: never = shape;
            return _exhaustiveCheck;
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, if we were to add a new shape type but forget to update the area function, TypeScript would give us a compile-time error. This ensures that all cases are handled and makes refactoring safer.

10. Mapped Types with Key Filtering

Mapped types can be combined with conditional types to filter object keys based on their value types. This allows for powerful type transformations.

type PickByType<T, U> = {
    [P in keyof T as T[P] extends U ? P : never]: T[P]
};

interface Example {
    a: string;
    b: number;
    c: boolean;
    d: string;
}

type StringProps = PickByType<Example, string>;
// Equivalent to:
// {
//     a: string;
//     d: string;
// }

// Practical use case: creating a type for form field values
interface FormFields {
    name: string;
    email: string;
    age: number;
    newsletter: boolean;
}

type StringFields = PickByType<FormFields, string>;
// Equivalent to:
// {
//     name: string;
//     email: string;
// }

function validateStringFields(fields: StringFields) {
    // Implementation
}

validateStringFields({ name: "John", email: "john@example.com" }); // OK
// validateStringFields({ name: "John", age: 30 }); // Error: Object literal may only specify known properties, and 'age' does not exist in type 'StringFields'.
Enter fullscreen mode Exit fullscreen mode

This technique is particularly useful when you need to work with subsets of object properties based on their types, such as in form validation or data transformation scenarios.

These additional five tips showcase more of TypeScript's advanced type manipulation capabilities. They demonstrate how TypeScript's type system can be leveraged to create highly specific and safe types, leading to more robust and self-documenting code. In the final section, we'll explore even more advanced concepts that push the boundaries of what's possible with TypeScript's type system.

11. Type-Safe Event Emitters Using Generics

Creating type-safe event emitters can significantly improve the reliability of event-driven code. By leveraging generics, we can ensure that event names and their corresponding data types are always in sync.

type Listener<T> = (event: T) => void;

class TypedEventEmitter<EventMap extends Record<string, any>> {
    private listeners: { [K in keyof EventMap]?: Listener<EventMap[K]>[] } = {};

    on<K extends keyof EventMap>(event: K, listener: Listener<EventMap[K]>) {
        if (!this.listeners[event]) {
            this.listeners[event] = [];
        }
        this.listeners[event]!.push(listener);
    }

    emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
        this.listeners[event]?.forEach(listener => listener(data));
    }
}

// Usage
interface MyEvents {
    userLoggedIn: { userId: string; timestamp: number };
    dataLoaded: { items: string[] };
}

const emitter = new TypedEventEmitter<MyEvents>();

emitter.on("userLoggedIn", ({ userId, timestamp }) => {
    console.log(`User ${userId} logged in at ${timestamp}`);
});

emitter.emit("userLoggedIn", { userId: "123", timestamp: Date.now() }); // OK
// emitter.emit("userLoggedIn", { userId: "123" }); // Error: Property 'timestamp' is missing
// emitter.emit("invalidEvent", {}); // Error: Argument of type '"invalidEvent"' is not assignable to parameter of type 'keyof MyEvents'
Enter fullscreen mode Exit fullscreen mode

This pattern ensures that your event-driven code is type-safe, preventing errors from mismatched event names or incorrect data structures.

12. Self-Referencing Types

Self-referencing types are useful when working with recursive data structures, such as tree-like objects or linked lists.

type FileSystemObject = {
    name: string;
    size: number;
    isDirectory: boolean;
    children?: FileSystemObject[];
};

const fileSystem: FileSystemObject = {
    name: "root",
    size: 1024,
    isDirectory: true,
    children: [
        {
            name: "documents",
            size: 512,
            isDirectory: true,
            children: [
                { name: "report.pdf", size: 128, isDirectory: false },
                { name: "invoice.docx", size: 64, isDirectory: false }
            ]
        },
        { name: "image.jpg", size: 256, isDirectory: false }
    ]
};

function calculateTotalSize(fsObject: FileSystemObject): number {
    if (!fsObject.isDirectory) {
        return fsObject.size;
    }
    return fsObject.size + (fsObject.children?.reduce((total, child) => total + calculateTotalSize(child), 0) ?? 0);
}

console.log(calculateTotalSize(fileSystem)); // Outputs the total size of all files
Enter fullscreen mode Exit fullscreen mode

This technique allows you to model complex, nested structures while maintaining type safety throughout your operations on those structures.

13. Opaque Types Using Unique Symbols

Opaque types provide a way to create types that are structurally similar but treated as distinct by the type system. This is useful for creating type-safe identifiers or preventing accidental misuse of similar types.

declare const brand: unique symbol;

type Brand<T, TBrand> = T & { readonly [brand]: TBrand };

type Email = Brand<string, "Email">;
type UserId = Brand<string, "UserId">;

function createEmail(email: string): Email {
    // In a real application, you'd validate the email here
    return email as Email;
}

function sendEmail(email: Email, message: string) {
    console.log(`Sending "${message}" to ${email}`);
}

const email = createEmail("user@example.com");
const userId = "12345" as UserId;

sendEmail(email, "Hello!"); // OK
// sendEmail(userId, "Hello!"); // Error: Argument of type 'UserId' is not assignable to parameter of type 'Email'
Enter fullscreen mode Exit fullscreen mode

This pattern is particularly useful when working with domain-specific types that should not be interchangeable, even if they share the same underlying structure.

14. Type-Level Integer Sequences

Creating integer sequences at the type level can be useful for more advanced type manipulations, especially when working with tuples or arrays of specific lengths.

type BuildTuple<L extends number, T extends any[] = []> =
    T['length'] extends L ? T : BuildTuple<L, [...T, any]>;

type Range<F extends number, T extends number> = Exclude<BuildTuple<T>[number], BuildTuple<F>[number]>;

type NumRange = Range<2, 5>; // type NumRange = 2 | 3 | 4

function createArray<T, N extends number>(element: T, length: Range<1, 11>): T[] {
    return Array(length).fill(element);
}

const arr1 = createArray("hello", 5); // OK
// const arr2 = createArray("world", 0); // Error: Argument of type '0' is not assignable to parameter of type 'Range<1, 11>'
// const arr3 = createArray("!", 11); // Error: Argument of type '11' is not assignable to parameter of type 'Range<1, 11>'
Enter fullscreen mode Exit fullscreen mode

This advanced technique allows you to create more precise types for array operations, ensuring that array lengths fall within specific ranges at compile-time.

15. Type-Safe Deep Partial Using Recursive Conditional Types

When working with complex nested objects, it's often useful to have a DeepPartial type that makes all properties optional recursively. This can be achieved using recursive conditional types.

type DeepPartial<T> = T extends object ? {
    [P in keyof T]?: DeepPartial<T[P]>;
} : T;

interface NestedObject {
    a: {
        b: {
            c: number;
            d: string;
        };
        e: boolean;
    };
    f: string[];
}

type PartialNested = DeepPartial<NestedObject>;

// Usage
function updateNestedObject(obj: NestedObject, update: DeepPartial<NestedObject>): NestedObject {
    // Implementation (deep merge logic)
    return { ...obj, ...update } as NestedObject; // Simplified for brevity
}

const original: NestedObject = {
    a: { b: { c: 1, d: "hello" }, e: true },
    f: ["one", "two"]
};

const updated = updateNestedObject(original, {
    a: { b: { c: 2 } },
    f: ["three"]
});

console.log(updated);
// Output: { a: { b: { c: 2, d: "hello" }, e: true }, f: ["three"] }
Enter fullscreen mode Exit fullscreen mode

This DeepPartial type is especially useful when working with partial updates to complex objects, such as in state management systems or when dealing with API responses that may contain partial data.

The End

Remember, while these advanced features are powerful, they should be used judiciously. Always strive for clarity and simplicity in your codebase, reaching for these advanced techniques when they provide clear benefits in type safety or developer experience.

Also, shameless plug πŸ”Œ. If you work in an agile dev team and use tools for your online meetings like planning poker or retrospectives, check out my free tool called Kollabe!

Top comments (0)