Getting Started With Testing Types in TypeScript

A guide on testing advanced types such as Generics or Conditional Types in TypeScript

Nic Jennings
Better Programming

--

Source

As soon as you start writing more advanced types such as Generics or Conditional Types in TypeScript then it should become a crucial step in checking that your type is doing exactly what you expect. Testing Types is a little different to writing your normal Unit Tests as we are testing untranspiled code.

There are three main options when it comes to testing types: Using a tool like dtslint, tsd or making use of the @ts-expect-error comment. I will be covering @ts-expect-error as this is the easiest way to get started and it requires no additional tooling.

I will be using two comments @ts-expect-error and @ts-expect-no-error.
@ts-expect-no-error is purely descriptive as it has no functionality other than making it clear we expect the next line to not throw an error.

All the code in this article is available here: https://github.com/nic-jennings/testing-types-in-typescript

Testing Pick<Type, Keys>

Lets start by testing the built-in Type Pick<Type, Keys>. If we were making this Type we would want to ensure it returns the Types we have picked or return never when the key doesn’t exist on the original Type.

I will be using the following Type to write the all tests in this article against:

type MainObject = {
id: string;
name: string;
nested: {
id: string;
name: string;
};
nestedArray: MainObject["nested"][];
};

The tests will be written in the following format:

  • Create a let variable which is constrained to our Type and named like the expected outcome.
  • Beneath either a @ts-expect-error or @ts-expect-no-error, assign values to the variables to ensure our Type creates the constraints we expect.

We need to test if it is possible to constrain a variable based on the Type we Pick from MainObject.

let PickSelected: Pick<MainObject, "id">;// @ts-expect-no-error
PickSelected = { id: "someString" };
// @ts-expect-error
PickSelected = { name: "someString" };

The above test clarifies that it is possible to constrain a variable to just the Picked Type, but this then raises the question: does it constrain the Type to a Type of Object?

let PickObjectType: Pick<MainObject, "nested">;
// @ts-expect-no-error
PickObjectType = {
nested: { id: "someString", name: "something" }
};
// @ts-expect-error
PickNestedObject = { id: "someString" };
// @ts-expect-error
PickObjectType = { nested: { id: "someString"} }

We can constrain to a Type of Object but it has to match the entire Object. The final test is to assert that is not possible to Pick an item not in our MainObject. A simple one lined proves this is true:

// @ts-expect-error
let PickItemNotInObject: Pick<MainObject, "bla">;

The above examples demonstrate a fairly simple advanced type to test, but what if we had something considerably more complex.

Testing Custom Types

To give some clarity on this section of the article, I wanted to create a Type that would allow me to create a subset of other types while explicitly only returning the intersection of the two Types. To achieve this I needed to create two Types.

Please note: this Type is a work in progress but below is the current setup:

ExtractKeys

/** 
* Extract the intersecting Keys from two Objects.
* Right Object is the source of truth.
*/
export type ExtractKeys<T, Obj> = Extract<keyof T, keyof Obj>;

SubSet<T, Obj>

/**
* Returns a new Type where Two Objects recursively intersect.
* Right Object is the source of truth.
*/
export type SubSet<T, Obj> = ExtractKeys<T, Obj> extends never
? never
: {
[Key in ExtractKeys<T, Obj>]: Obj[Key] extends object
? Obj[Key] extends Array<object>
? number extends keyof Obj[Key] & keyof T[Key]
? SubSet<T[Key][number], Obj[Key][number]>[]
: never
: SubSet<T[Key], Obj[Key]>
: Obj[Key] | undefined extends T[Key]
? Obj[Key] | undefined
: Obj[Key] extends T[Key]
? Obj[Key]
: never;
};

This will require two tests, one to ensure that ExtractKeys infact returns the intersecting keys of two Types and SubSet creates a new Type from the intersection of the two Types provided as generics.

For ExtractKeys I wrote the following tests:

let NoKeys: ExtractKeys<{}, MainObject>;
// @ts-expect-error
NoKeys = "";
let MainOnlyKey: ExtractKeys<{ id: string }, MainObject>;
// @ts-expect-no-error
MainOnlyKey = "id";
// @ts-expect-error
MainOnlyKey = "name";
let KeyNotInMainObject: ExtractKeys<MainObject, MainObject>;
// @ts-expect-no-error
KeyNotInMainObject = "id";
// @ts-expect-error
KeyNotInMainObject = "bla";
let RequestedKeyNotInMainObject: ExtractKeys<MainObject & { bla: string }, MainObject>;
// @ts-expect-no-error
RequestedKeyNotInMainObject = "id";
// @ts-expect-error
RequestedKeyNotInMainObject = "bla";

This allows me to see I am getting back only the intersecting keys as expected so I can move onto testing the SubSet Type tests.

For this test I created the following Object which matches the original Type as I found I was duplicating very similar code in the tests.

const MainObjectValue: MainObject = {
id: "bla",
name: "bla",
nested: {
id: "bla",
name: "bla",
},
nestedArray: [
{
id: "bla",
name: "bla",
},
],
};

From the tests below I know know the SubSet Type is returning the recursive intersection of the two Types and constrainting variables to the new Type we have created.

/**
* Describe: SubSet
*/
let SubSetNoKeys: SubSet<{ bla: unknown }, MainObject>;
// @ts-expect-error
SubSetNoKeys = {};
// @ts-expect-error
SubSetNoKeys = { id: "bla" };
let SubSetMainOnlyKey: SubSet<{ id: string }, MainObject>;
// @ts-expect-no-error
SubSetMainOnlyKey = { id: "bla" };
// @ts-expect-error
SubSetMainOnlyKey = { name: "bla" };
let SubSetKeyOptional: SubSet<{ id?: string; name: string }, MainObject>;
// @ts-expect-no-error
SubSetKeyOptional = { id: "bla", name: "bla" };
// @ts-expect-no-error
SubSetKeyOptional = { id: undefined, name: "bla" };
// @ts-expect-error
SubSetKeyOptional = { name: "bla" };
let SubSetMisMatchType: SubSet<{ id: number }, { id: string }>;
// @ts-expect-error
SubSetMisMatchType = { id: "bla" };
// @ts-expect-error
SubSetMisMatchType = { id: 1 };
let SubSetKeyNotInMainObject: SubSet<MainObject, MainObject>;
// @ts-expect-no-error
SubSetKeyNotInMainObject = MainObjectValue;
// @ts-expect-error
SubSetKeyNotInMainObject = { id: "bla" };
// @ts-expect-error
SubSetKeyNotInMainObject = { bla: "bla" };
let SubSetRequestedKeyNotInMainObject: SubSet<
MainObject & { bla: string }, MainObject>;
// @ts-expect-no-error
SubSetKeyNotInMainObject = MainObjectValue;
// @ts-expect-error
SubSetRequestedKeyNotInMainObject = { id: "bla" };
// @ts-expect-error
SubSetRequestedKeyNotInMainObject = { ...MainObjectValue, bla: "bla" };
let NestedSubSetKeyInMainObject: SubSet<
{
nested: {
id: string;
};
}, MainObject>;
// @ts-expect-no-error
NestedSubSetKeyInMainObject = MainObjectValue;
// @ts-expect-no-error
NestedSubSetKeyInMainObject = { nested: { id: "bla" } };
// @ts-expect-error
NestedSubSetKeyInMainObject = { nested: { name: "bla" } };
let NestedSubSetKeyNotInMainObject: SubSet<
{
nested: {
bla: string;
};
},
MainObject>;
// @ts-expect-no-error
NestedSubSetKeyNotInMainObject = { nested: null as never };
// @ts-expect-error
NestedSubSetKeyNotInMainObject = MainObjectValue;
// @ts-expect-error
NestedSubSetKeyNotInMainObject = { nested: { id: "bla" } };
// @ts-expect-error
NestedSubSetKeyNotInMainObject = { nested: { name: "bla" } };
let NestedArraySubSetKeyNotInMainObject: SubSet<
{
nestedArray: {
id: string;
}[];
},
MainObject>;
// @ts-expect-no-error
NestedArraySubSetKeyNotInMainObject = MainObjectValue;
// @ts-expect-no-error
NestedArraySubSetKeyNotInMainObject = { nestedArray: [{ id: "bla" }] };
// @ts-expect-error
NestedArraySubSetKeyNotInMainObject = { nestedArray: [{ name: "bla" }] };

This would be the time to go refactor the SubSet Type as we have wrapped our required funtionality in tests so we can ensure our Type still works as expect when we make improvements and changes.

Conclusion

In my opinion, the beauty of testing in this way is that it allows you to write your Types using TDD (Red / Green Testing). It will also aid you in learning what is actually happening when you start creating Advanced Types and finally it makes it clear to other Software Engineers / Developers what your Type is doing and ensures that it is not functionally changed without due care and consideration.

I personally would not recommend writing Type tests for simple Type definitions such as parameters on a React component but as soon as there could be any ambiguity or cognitive load when reading a Type then testing is crucial.

Thank you for taking the time to read my article. I hope you have found it informative and interesting. I will be writing more articles around Typescript, Node, React, Vue, GraphQL, Performance, Go, and much more.

Other considerations

If you are using eslint as I am sure most of us are then you may want to disable the following rules at the top of your file:

/* eslint-disable prefer-const */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/ban-ts-comment */

--

--

Full-Stack Software Engineer dedicated and passionate about development, UI and solving problems.