DEV Community

Cover image for 10 Сustom Utility Types for TypeScript Projects
Anton Zamay
Anton Zamay

Posted on

10 Сustom Utility Types for TypeScript Projects

In the dynamic landscape of TypeScript development, utility types stand as base tools for crafting adaptable, clear, and robust type arrangements. This article introduces 10 widely-used utility types that tackle common coding challenges, from manipulating primitive types to fine-tuning object properties for comprehensive control over immutability and optionality.

TOC

Primitive

The type Primitive represents the set of all basic data types in JavaScript (TypeScript). Primitive can be useful for functions or variables that may need to handle a range of simple data types.

type Primitive = string | number | bigint | boolean | symbol | 
    null | undefined;
Enter fullscreen mode Exit fullscreen mode

Example

The following example demonstrates how filters can accommodate various primitive data types effectively.

interface Product {
  id: symbol; // Unique identifier
  name: string;
  price: number;
  available: boolean;
  totalSales: bigint; // Large number representing total sales
}

// The filter definition
interface FilterDefinition {
  key: keyof Product;
  value: Primitive;
}

// Function to filter products
function filterProducts(
  products: Product[],
  filters: FilterDefinition[]
): Product[] {
  return products.filter((product) => {
    return filters.every((filter) => {
      return product[filter.key] === filter.value;
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Falsy

The type Falsy encompasses all possible values that JavaScript (TypeScript) considers "falsy". In JavaScript, a value is considered falsy if it translates to false when evaluated in a boolean context (e.g., in an if statement). This type is optimally designed for scenarios involving type coercion to Boolean across different primitive types.

type Falsy = false | "" | 0 | 0n | null | undefined;
Enter fullscreen mode Exit fullscreen mode

Example
For instance, consider a form field that may accept several falsy values, including null, false, 0, and empty string.

// A utility function that returns a default value if the
// input is Falsy
function getDefaultIfFalsy<T>(
  value: T | Falsy, 
  defaultValue: T
): T {
  return value || defaultValue;
}

// Define form data interface
interface FormData {
  name: string;
  email: string;
  age: number;
  billingAddress: string;
  shippingAddress?: string;
  sameAsBillingAddress: boolean;
}

// Use `getDefaultIfFalsy` for `shippingAddress`
formData.shippingAddress = 
  getDefaultIfFalsy(formData.shippingAddress, "");
Enter fullscreen mode Exit fullscreen mode

Truthy

This construction allows Truthy<T> to be used to filter out falsy values from type unions, preserving only those types that are considered truthy in JavaScript.

type Truthy<T> = T extends Falsy ? never : T;
Enter fullscreen mode Exit fullscreen mode

Here's how it can work in practice:

// Result: 1 | {}
type Example = Truthy<"" | 1 | false | {} | undefined>;
Enter fullscreen mode Exit fullscreen mode

Example

Utilizing the Truthy type, you can create a function that takes an object with optional properties as input and returns an object with only the properties that were filled out (truthy values), fully typed.

function processInput<T extends object>(
  obj: T
): {[K in keyof T]: Truthy<T[K]>} {
  const result: Partial<{[K in keyof T]: Truthy<T[K]>}> = {};
  Object.entries(obj).forEach(([key, value]) => {
    if (value) {
      result[key as keyof T] = value as any;
    }
  });
  return result as {[K in keyof T]: Truthy<T[K]>};
}

const userInput = {
  name: "John",
  age: 0,
  email: ""
};

const processedInput = processInput(userInput);
console.log(processedInput); // Output: { name: "John" }
Enter fullscreen mode Exit fullscreen mode

Nullish

The Nullish type indicates the absence of a value or signifies that a variable has not been initialized. Its primary purpose is to handle optional properties, variables or function parameters that may not always have a value. It allows to distinguish between missing values and values that are present, but with falsy values like 0, false, or an empty string. Therefore, using Nullish helps enhance the reliability of the code by explicitly handling these null or undefined cases.

type Nullish = null | undefined;
Enter fullscreen mode Exit fullscreen mode

Example

In the example, Nullish is used to manage optional UserInfo properties, allowing the function getFoodRecommendations to default to specific values when those properties are not provided. This approach ensures the function can handle cases where the properties are either null or undefined, preventing potential errors that could arise from directly accessing unset or optional properties.

interface UserInfo {
  name: string;
  favoriteFood?: string | Nullish;
  dietRestrictions?: string | Nullish;
}

function getFoodRecommendations(user: UserInfo) {
  const favoriteFood = user.favoriteFood ?? 'Generic';
  const dietRestriction = user.dietRestrictions ?? 'No Special Diet';

  // In a real app, there could be a complex logic to get proper food recommendations.
  // Here, just return a simple string for demonstration.
  return `Recommendations: ${favoriteFood}, ${dietRestriction}`;
}
Enter fullscreen mode Exit fullscreen mode

NonNullableKeys

The NonNullableKeys type construction is used to filter out the keys of an object type that are associated with nullable (i.e., null or undefined) values. This utility type is particularly useful in scenarios where it's necessary to ensure the access of only those properties of an object that are guaranteed to be non-null and non-undefined. It can be applied, for example, in functions that require strict type safety and cannot operate on nullable properties without explicit checks.

type NonNullableKeys<T> = { 
    [K in keyof T]: T[K] extends Nullish ? never : K 
}[keyof T];
Enter fullscreen mode Exit fullscreen mode

Example

In the example below, we introduce a UserProfile interface with various properties. Using NonNullableKeys, we implement a prepareProfileUpdate function. This function filters out nullable properties from a user profile update object, ensuring that only properties with meaningful (non-null/undefined) values are included in the update payload. This approach can be especially valuable in API interactions where avoiding null/undefined data submission is desired for maintaining data integrity in backend systems.

Example

interface UserProfile {
  id: string;
  name: string | null;
  email?: string | null;
  bio?: string;
  lastLogin: Date | null;
}

function prepareProfileUpdate<T extends object>(
  profile: T
): Pick<T, NonNullableKeys<T>> {
  const updatePayload: Partial<T> = {};
  (Object.keys(profile) as Array<keyof T>).forEach(key => {
    const isValuePresent = profile[key] !== null &&
                           profile[key] !== undefined;
    if (isValuePresent) {
      updatePayload[key] = profile[key];
    }
  });
  return updatePayload as Pick<T, NonNullableKeys<T>>;
}

const userProfileUpdate: UserProfile = {
  id: "123",
  name: "John Doe",
  email: null,
  bio: "Software Developer",
  lastLogin: null,
};

const validProfileUpdate = prepareProfileUpdate(
  userProfileUpdate
);
// Output:
// { id: "123", name: "John Doe", bio: "Software Developer" }
console.log(validProfileUpdate);
Enter fullscreen mode Exit fullscreen mode

JSONObject

The JSONObject type is useful for defining the shape of objects that can be converted to or from JSON without loss or when interfacing with APIs that communicate using JSON. It employs a construction which is known as a recursive type or mutual recursion, wherein two or more types depend on each other. Recursive types are useful for describing data structures that can nest within themselves to an arbitrary dept.

type JSONObject = { [key: string]: JSONValue };
type JSONValue = string | number | boolean | null | JSONObject | 
  JSONValue[];
Enter fullscreen mode Exit fullscreen mode

Example
In this example, we define a configuration object for an application. This configuration object must be serializable to JSON, so we enforce its shape to conform to the JSONObject type.

function saveConfiguration(config: JSONObject) {
  const serializedConfig = JSON.stringify(config);
  // In a real application, this string could be saved to a file,
  // sent to a server, etc.
  console.log(`Configuration saved: ${serializedConfig}`);
}

const appConfig: JSONObject = {
  user: {
    name: "John Doe",
    preferences: {
      theme: "dark",
      notifications: true,
    },
  },
  version: 1,
  debug: false,
};

saveConfiguration(appConfig);
Enter fullscreen mode Exit fullscreen mode

OptionalExceptFor

The OptionalExceptFor type is a utility type that takes an object type T and a set of keys TRequiredKeys from T, making all properties optional except for the specified keys. It's useful in scenarios where most properties of an object are optional, but a few are mandatory. This type facilitates a more flexible approach to typing objects without having to create multiple interfaces or types for variations of optional properties, especially in configurations, where only a subset of properties is required to be present.

type OptionalExceptFor<T, TRequiredKeys extends keyof T> = 
    Partial<T> & Pick<T, TRequiredKeys>;
Enter fullscreen mode Exit fullscreen mode

Example

In the following example, the OptionalExceptFor type is used to define an interface for user settings where only the userId is required, and all other properties are optional. This allows for more flexible object creation while ensuring that the userId property must always be provided.

interface UserSettings {
  userId: number;
  notifications: boolean;
  theme: string;
  language: string;
}

type SettingsWithMandatoryID =
  OptionalExceptFor<UserSettings, 'userId'>;

const userSettings: SettingsWithMandatoryID = {
  userId: 123,
  // Optional: 'notifications', 'theme', 'language'
  theme: 'dark',
};

function configureSettings(
  settings: SettingsWithMandatoryID
) {
  // Configure user settings logic
}

configureSettings(userSettings);
Enter fullscreen mode Exit fullscreen mode

ReadonlyDeep

The ReadonlyDeep type is a utility that makes all properties of a given type T read-only, deeply. This means that not only are the top-level properties of the object made immutable, but all nested properties are also recursively marked as read-only. This type is particularly useful in scenarios where immutability is paramount, such as in Redux state management, where preventing unintended state mutations is crucial.

type ReadonlyDeep<T> = {
  readonly [P in keyof T]: T[P] extends object ? 
  ReadonlyDeep<T[P]> : T[P];
};
Enter fullscreen mode Exit fullscreen mode

Example
The following example ensures that neither the Person object itself nor any of its nested properties can be modified, thus demonstrating how the ReadonlyDeep type can be applied to ensure deep immutability.

interface Address {
  street: string;
  city: string;
}

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

const person: ReadonlyDeep<Person> = {
  name: "Anton Zamay",
  age: 25,
  address: {
    street: "Secret Street 123",
    city: "Berlin",
  },
};

// Error: Cannot assign to 'name' because it is a read-only
// property.
person.name = "Antonio Zamay";
// Error: Cannot assign to 'city' because it is a read-only
// property.
person.address.city = "San Francisco";
Enter fullscreen mode Exit fullscreen mode

PartialDeep

The PartialDeep type recursively makes all properties of an object type T optional, deeply. This type is particularly useful in scenarios where you're working with complex nested objects and need a way to partially update or specify them. For example, when handling state updates in large data structures without the need to specify every nested field, or when defining configurations that can override defaults at multiple levels.

type PartialDeep<T> = {
   [P in keyof T]?: T[P] extends object ? PartialDeep<T[P]> : 
   T[P];
};
Enter fullscreen mode Exit fullscreen mode

Example

In the example below, we use the PartialDeep type to define a function updateUserProfile that can accept partial updates to a user profile, including updates to nested objects such as address and preferences.

interface UserProfile {
  username: string;
  age: number;
  address: {
    street: string;
    city: string;
  };
  preferences: {
    newsletter: boolean;
  };
}

function updateUserProfile(
  user: UserProfile,
  updates: PartialDeep<UserProfile>
): UserProfile {
  // Implementation for merging updates into user's profile
  return { ...user, ...updates };
}

const currentUser: UserProfile = {
  username: 'johndoe',
  age: 30,
  address: {
    street: '123 Elm St',
    city: 'Anytown',
  },
  preferences: {
    newsletter: true,
  },
};

const userProfileUpdates: PartialDeep<UserProfile> = {
  address: {
    city: 'New City',
  },
};

const updatedProfile = updateUserProfile(
  currentUser,
  userProfileUpdates
);
Enter fullscreen mode Exit fullscreen mode

Brand

The Brand type is a TypeScript utility that employs nominal typing for otherwise structurally identical types. TypeScript’s type system is structural, meaning that two objects are considered the same type if they have the same shape, regardless of the names or locations of their declarations. However, there are scenarios where treating two identically shaped objects as distinct types is beneficial, such as differentiating between types that are essentially the same but serve different purposes (e.g., user IDs and order IDs both being strings but representing different concepts). The Brand type works by intersecting a type T with a unique branding object, effectively differentiating otherwise identical types without changing the runtime behavior.

type Brand<T, B> = T & { __brand: B };
Enter fullscreen mode Exit fullscreen mode

Example

Imagine an application where both user IDs and order IDs are represented as strings. Without branding, these could be confused, leading to potential bugs. Using the Brand type, we can create two distinct types, UserId and OrderId, making it impossible to mistakenly assign one to the other.

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

function fetchUserById(id: UserId) {
  // Fetch user logic here
}

function fetchOrderByOrderId(id: OrderId) {
  // Fetch order logic here
}

// Creation of branded types
const userId: UserId = '12345' as UserId;
const orderId: OrderId = '67890' as OrderId;

// These calls are safe and clear
fetchUserById(userId); 
fetchOrderByOrderId(orderId);

// Error: Argument of type 'OrderId' is not assignable
// to parameter of type 'UserId'
fetchUserById(orderId);
Enter fullscreen mode Exit fullscreen mode

Top comments (9)

Collapse
 
bcostaaa01 profile image
Bruno

Quite handy stuff for Typescript! The JSONObject caught my attention particularly, might come handy in form submitting scenarios, for example. Nice read 👍

Collapse
 
coolcucumbercat profile image
coolCucumber-cat

Instead of NonNullableKeys, you should create a type that lets you pass in any type instead of Nullish, and then if you want non nullable keys, you just pass in Nullish. That way you can also reuse it for other types, not just Nullish.

Collapse
 
antonzo profile image
Anton Zamay

Good suggestion! I will provide corresponding types if you mentioned the more general solution anyways. These types are basically extensions of Pick and Omit, but with the Condition:

type FilterKeys<T, Condition> = {
  [K in keyof T]: K extends Condition ? K : never
}[keyof T];

type PickByCondition<T, Condition> = Pick<T, FilterKeys<T, Condition>>;
type OmitByCondition<T, Condition> = Omit<T, FilterKeys<T, Condition>>;
Enter fullscreen mode Exit fullscreen mode
Collapse
 
tmish profile image
Timur Mishagin

Great article! Thanks!

Collapse
 
filipdanic profile image
Filip Danić • Edited

Really, nice article, but the ReadOnlyDeep utility needs some more work. It does not cover arrays.

Example:

Collapse
 
antonzo profile image
Anton Zamay

Hey, thanks for your comment!

If a property's type is an object (which includes arrays, as arrays are objects in JavaScript), the type recursively applies the ReadonlyDeep transformation to that object. So it should work.

Try ReadonlyDeep instead of Readonly in your example.

Collapse
 
filipdanic profile image
Filip Danić

Ooops, you are completely right of course! I was comparing out a few different things in the playground and didn’t notice I made the switch. I was really confused too since I know I saw you used a recursive type – for a moment I thought this was a TS bug!

Thread Thread
 
antonzo profile image
Anton Zamay

That's ok, always happens with me too :D

Collapse
 
nickhodges profile image
Nick Hodges

I love this article so much.