2

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)
Valentine Shi
  • 6,604
  • 4
  • 46
  • 46
user10741122
  • 781
  • 1
  • 12
  • 26
  • 2
    Hmm, I really wish this code were a [mcve] suitable for dropping into a standalone IDE without typos or other issues. – jcalz Mar 17 '21 at 19:46
  • If you're looking for a generic solution here, you shouldn't be testing for `undefined` with `if (!object[key])...` because this will give false positive for some numbers, some boolean values, empty strings and so on... `typeof(object[key]) === "undefined"` would be a more robust test. – spender Mar 17 '21 at 21:22
  • @jcalz looks like plenty of others managed. Sorry for your struggles. – user10741122 Mar 17 '21 at 22:02

3 Answers3

7

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

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
3

You can use the following type:

type Require<T, K extends keyof T> = T & { [P in K]-?: T[P]; }

to make certain props required. e.g.

type P = Require<Person, "name" | "color">

So you could now use this to type your function:

const throwIfUndefined = <T, K extends keyof T>(
    object: T,
    requiredKeys: K[]
): Require<T, K> => {
    if(requiredKeys.some(key => typeof object[key] === "undefined"))
        throw new Error("missing required key");    
    return object as Require<T, K>;
};
spender
  • 117,338
  • 33
  • 229
  • 351
0

Please consider next example.

I think typeguards is the most suitable solution here:

type Person = {
    name?: string;
    color?: string;
    address?: string;
}

const hasProperty=<T,P extends string>(obj:T,prop:P):obj is T & Record<P, unknown>=>
    Object.prototype.hasOwnProperty.call(obj, prop);

const throwIfUndefined = <T, K extends string>(
    obj: T,
    requiredKeys: K[]
): obj is T & Record<K, unknown> => requiredKeys.every(key => hasProperty(obj,key));

const person = {
    name: 'John',
    color: 'red',
    address: 'Ternopil'
}

const check = (arg: unknown) => {
    if (throwIfUndefined(arg, ['name'])) {
        const x = arg.name // ok
    }

    if (throwIfUndefined(arg, ['name', 'age'])) {
        const x = arg.name // ok
        const y = arg.age // ok
        const z = arg.other // error

    }
}

I decided to just return boolean from throwIfUndefined, because thats how typeguardss work. Here you have fully generic solution. throwIfUndefined(arg, ['name', 'age']) means that arg variable 100% contains name and age properties

If you still want to throw an errors, you can use assert functions.

Here you can find the docs.