· 4 min read

Avoid type casts in tests

Improve your TypeScript test code with this one simple trick.

When writing tests, it’s common to have objects with many required properties. To satisfy the type system, we can do a type cast and only specify the needed properties. However, this can be problematic. In this post, I’ll discuss why this isn’t a good practice and provide a better solution.

Why are type casts used in tests?

Imagine we have a greet function that takes a User and displays their name and email address.

interface User {
  id: string;
  name: string;
  email: string;
  address: string;
}

function greet(user: User) {
  return `Hello, ${user.name}. Your email address is ${user.email}.`;
}

Let’s add a test to verify that the function works as intended.

test('should greet the person', () => {
  const user: User = {
    id: 'some-id',
    name: 'Alice',
    email: 'alice@example.com',
    address: 'Main Street 12',
  };
  expect(greet(user)).toBe('Hello, Alice. Your email address is alice@example.com.');
});

We create a User object and pass it to the greet function, verifying that the return is correct. However, we must set all properties for the User interface, even though they are not used in this test - in this case, only name and email are necessary.

So, how do we solve this? A solution I frequently see is to use a type cast.

test('should greet the person', () => {
  // ❌ avoid type casts
  const user = {
    name: 'Alice',
    email: 'alice@example.com',
  } as User;
  expect(greet(user)).toBe('Hello, Alice. Your email address is alice@example.com.');
});

Now, we only need to declare the name and email and the test passes. Problem solved. However, I think that there are issues with this approach and want to present a better alternative.

The problem with type casts

Imagine that we need to add a setting that allows users to hide their email address in the greeting.

interface User {
  id: string;
  name: string;
  email: string;
  address: string;
  settings: {
    hideEmail?: boolean;
  };
}

function greet(user: User) {
  if (user.settings.hideEmail) {
    return `Hello, ${user.name}.`;
  }
  return `Hello, ${user.name}. Your email address is ${user.email}.`;
}

If we run our existing test, it will crash with the error message:

TypeError: Cannot read properties of undefined (reading 'hideEmail')

Our user object lacks the settings property, which would be detected by Typescript if we didn’t use a type cast. This results in a runtime error that points us to the wrong code location.

Let’s update our test:

test('should greet the person', () => {
  // ❌ avoid type casts
  const user = {
    name: 'Alice',
    email: 'alice@example.com',
    settings: {},
  } as User;
  expect(greet(user)).toBe('Hello, Alice. Your email address is alice@example.com.');
});

This solution brings us back to what we were trying to avoid: declaring a property that is irrelevant to our test.

A better solution

Instead of type casting, use a helper function to create a valid user object.

Define a default object of type User and merge in overrides for the values you want to set. Use the deepmerge library and a DeepPartial type to do this.

import merge from 'deepmerge';

const defaultUser: User = {
  id: 'default-id',
  name: 'Default Name',
  email: 'default@example.com',
  address: 'Default Address',
  settings: {},
};

function createUser(overrides: DeepPartial<User> = {}) {
  return merge(defaultUser, overrides);
}

test('should greet the person', () => {
  // ✅ use a helper function
  const user = createUser({ name: 'Alice', email: 'alice@example.com' });
  expect(greet(user)).toBe('Hello, Alice. Your email address is alice@example.com.');
});

Using this approach, your test code will never fail because of missing properties. Adding a new property to an interface requires you to add the default value to a single place. The values for the defaultUser can be generated by libraries like Faker. The same helper function can be used in all test suites, which improves readability and maintainability.

Let’s add a second test case:

test('should hide email in greeting when configured', () => {
  const user = createUser({
    email: 'alice@example.com',
    settings: { hideEmail: true },
  });
  expect(greet(user)).not.toContain(user.email);
});

We no longer need to specify the name, as it is irrelevant to the test. Although not necessary, I prefer to include the email because it’s used in the assertion.

Conclusion

In conclusion, using type casts in tests can lead to hard-to-spot errors that should have been caught by the type checker. Instead, use a helper function. This approach leads to more maintainable test code.

    Share:
    « Back to Blog