Hello there. It’s another day, another opportunity to geek out and escape the crazy world out there. Well, you don’t have to escape it today. Let's look at three type functions that you can use in the real world? If this adventure makes improves the code you write, we can count that as a success.
The three types we’ll explore today go from simple to complex, each one building on the previous. Let’s dive in.
ValueOf
Typescript has a type operator called keyof
. According to the documentation,
The
keyof
operator takes an object type and produces a string or numeric literal union of its keys.
You can see in the linked documentation that if you had a Point
type as follows, you can extract its keys using keyof
.
interface Point {
x: number;
y: number
};
type P = keyof Point;
P
in the example above is "x" | "y"
. keyof
is very useful, lot’s of type wizardry depends on it. But sometimes you want the union of a record type’s values.
Suppose you had the following Location
type and you want the types of its fields, not the keys.
interface Location {
name: string;
point: Point;
}
That is, you want a type that's string | Point
rather than "name" | "point"
. Let’s write a ValueOf
type function that does that. Naming it ValueOf
is convenient and similar to keyof
.
type ValueOf<T extends unknown> = T[keyof T];
That's it. We can apply the type function as follows.
type StringOrPoint = ValueOf<Location>;
// StringOrPoint is the same as string | Point
RequireKeys
You may have a type, say Options
below, and you want to require that a certain key is always present.
interface Options {
checkTypes?: boolean;
checkCycles?: boolean;
warnOnError?: boolean;
}
Suppose you want to use Options
in a function where checkTypes
must be specified, and you want to help the clients of the function know remember that as they use it. You can transform Options
to require checkTypes
by writing RequireKeys<Options, "checkTypes">
. Let's now define RequireKeys
.
type RequireKeys<T, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
It first selects the keys of T
that we're interested in and makes them required. Then it takes the remaining keys of T
as they are and adds them to the final result type. Here's the step-by-step process.
type CheckTypesRequired = RequireKeys<Options, "checkTypes">;
= Required<Pick<Options, "checkTypes">> & Omit<Options, "checkTypes">;
= Required<{ checkTypes?: boolean; }> & { checkCycles?: boolean; warnOnError?: boolean };
= { checkTypes: boolean } & { checkCycles?: boolean; warnOnError?: boolean };
= { checkTypes: boolean; checkCycles?: boolean; warnOnError?: boolean }
Little gotcha
You can use RequireKeys
as is, but there's a gotcha. Suppose you have a function that accepts CheckTypesRequired
, and you pass it a Partial<Options>
, of course that won't work.
declare function f(_: CheckTypesRequired): void;
let data: Partial<Options>;
f(data); // this raises a type error, as expected.
But what if you do the following?
data.checkTypes != null ? f(data) : f({...data, checkTypes: true});
You will notice that even after checking that checkTypes
is present, we cannot simply pass it to f
. In fact, we cannot spread it to f
as in f({ ...data })
. That's because TypeScript doesn't refine the type of data
based on our check. However, the second branch of the ternary expression will type-check correctly because we specified the key ourselves.
NonEmptyRecord
The final type function we'll consider is NonEmptyRecord
. Let's consider another type, Query
. All its fields are optional.
interface Query {
id?: boolean;
email?: boolean;
referralCode?: boolean;
}
Suppose we want to require at least one option to query by. You can query for a unique account record by either its ID, its email, or its referral code. But it doesn't make sense to query for a unique record without any of these. We can indicate in the type of our query function by writing the following.t
declare function queryByUniqueId(_: NonEmptyRecord<Query>): Promise<Account>;
Now let's write NonEmptyRecord
🙂.
type NonEmptyRecord<T> = ValueOf<{
[I in keyof T]: RequireKeys<T, I>;
}>;
We're reusing the previous type functions we defined. We first project each key of T
into a record type with its value being T
, but with the particular key required. Then we extract the different cases of T
with a key required as a union. That is, we'll have a union of different variants of T
where one key is required. Here's the evaluation step-by-step.
type NonEmptyRecord<T> = ValueOf<{
[I in keyof T]: RequireKeys<T, I>;
}>;
type UniqueQuery = NonEmptyRecord<Query>
= ValueOf<{
id: RequireKeys<Query, "id">;
email: RequireKeys<Query, "email">;
referralCode: RequireKeys<Query, "referralCode">;
}>
= RequireKeys<Query, "id"> | RequireKeys<Query, "email"> | RequireKeys<Query, "referralCode">;
You can confirm the final form by expanding each RequireKeys
in the union as we did above.
Because this is TypeScript, there is another way to write NonEmptyRecord
. You can never lack when it comes to type-fu!
type NonEmptyRecord<T> = [keyof T] extends [infer H, ...infer Rest]
? H extends keyof T
? Rest extends keyof T
? // We're working with at least 2 keys of T.
// Either require H in T or make it optional and require some other key.
(NonEmptyRecord<Omit<T, H>> & Partial<Pick<T, H>>) | RequireKeys<T, H>
: Rest extends []
? // We're working with only one key of T. Require it.
RequireKeys<T, H>
: never
: // These `never`s won't happen. We're only directing the type checker.
never
: never;
Okay, this one was fun to write, but I've run out of steam here. I may come back to break down its evaluation later. It is functionally identical to the first implementation of NonEmptyRecord
, believe me. Alright?
Phewww! That was fun. I hope you enjoyed it. If you have any interesting type-fu to share with me, please do. I enjoy these things. Until next time.