Say you have an object type:
Type Person = { name?: string; color?: string; address? string; }
However you want to change that type into the following, where you know name and color will exist.
Type Person = { name: string; color: string; address? string; }
Therefore, there is the function
const throwIfUndefined = ( object: {[key: string]: any}, requiredKeys: string[] ): ReturnTypeHere => { for (const key of requiredKeys) { if (!object[key]) throw new Error("missing required key"); } return object; };
What is the proper way to type the params of the function as well as the return type (ReturnTypeHere
)? Written correctly, the below will either 1) throw error 2) console log the name. It will never console log undefined.
const person = {...} const requiredKeys = ["name", "color"]; const verifiedPerson = throwIfUndefined(person, requiredKeys); console.log(verifiedPerson.name)
Advertisement
Answer
If you have an object type T
and a union of its keys K
that you’d like to have required, you can write RequireKeys<T, K>
like this:
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
Here we are using the Required<T>
, Pick<T, K>
, and Omit<T, K>
utility types. There are probable edge cases here, such as if T
has a string index signature and string
appears inside K
, but to a first approximation it should work.
It’s also a little difficult to understand what RequiredKeys<Person, "name" | "color">
is from how it’s displayed in your IDE:
type VerifiedPerson = RequireKeys<Person, "name" | "color">; // type VerifiedPerson = Required<Pick<Person, "name" | "color">> & // Omit<Person, "name" | "color">
If you want the compiler to be a little more explicit, you can do something like the following to expand the type into its properties:
type RequireKeys<T extends object, K extends keyof T> = (Required<Pick<T, K>> & Omit<T, K>) extends infer O ? { [P in keyof O]: O[P] } : never;
which results in
/* type VerifiedPerson = { name: string; color: string; address?: string | undefined; } */
which is easier to see.
—
You then need to make throwIfUndefined()
a generic function so the compiler can keep track of the relationship between the object
and requiredKeys
passed in:
const throwIfUndefined = <T extends object, K extends keyof T>( object: T, requiredKeys: readonly K[] ) => { for (const key of requiredKeys) { if (!object[key]) throw new Error("missing required key"); } return object as unknown as RequireKeys<T, K> // need to assert this };
And to test:
const person: Person = { ...Math.random() < 0.8 ? { name: "Alice" } : {}, ...Math.random() < 0.8 ? { color: "Color for a person is problematic" } : {} }; const requiredKeys = ["name", "color"] as const; const verifiedPerson = throwIfUndefined(person, requiredKeys); // possible runtime error here // const verifiedPerson: RequireKeys<Person, "name" | "color">
If you want the compiler to remember that the literal types "name"
and "color"
are members of requiredKeys
then you need to do something like a const
assertion (i.e., as const
) to tell it so. Otherwise requiredKeys
would just be string[]
and you’d get weird/wrong results (we could guard against these but it would be possibly out of scope here).
And now, the compiler understands that name
and color
are defined, whereas address
is still optional:
console.log(verifiedPerson.name.toUpperCase() + ": " + verifiedPerson.color.toUpperCase()); // no compile error // [LOG]: "ALICE: COLOR FOR A PERSON IS PROBLEMATIC" verifiedPerson.address // (property) address?: string | undefined