简体   繁体   中英

Typescript: Assert unknown input has type Pick<ConcreteType, subset of keys of ConcreteType> for specified keys

When trying to create a generic function to test if an unknown input is a subset of a known object type I run into trouble with Typescript. I want to specify which keys should be present and assert that the input is of type Pick<ConcreteType, subset of keys of ConcreteType>. My assertion

Simplified code:

type Rectangle = {
  width: number,
  height: number
}

const assertObject: (o: unknown) => asserts o is Record<PropertyKey, unknown> = (
  o,
) => {
  if (typeof o !== `object`) {
    throw new Error();
  }
};

function assertRectangle <K extends keyof Rectangle>(o: unknown, ...keys: K[]): asserts o is Pick<Rectangle, K>  {
  assertObject(o);
  // >>>>>>>>>>>>>>>>>>>>>> HERE <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
  if (keys.includes(`width` as K) && !o.hasOwnProperty('width')) {
    throw new Error('');
  }
  if (keys.includes(`height` as K) && !o.hasOwnProperty('height')) {
    throw new Error('');
  }
}

const rect = {width: 1, height: 1};
assertRectangle(rect, 'width'); // Pick<Rectangle, "width">
assertRectangle(rect, 'height', 'width'); // Pick<Rectangle, "height" | "width">

This code works but not if we remove the as K inside keys.includes .

if (keys.includes(`width`) && !o.hasOwnProperty('width')) {
   throw new Error('');
}
// OR:
if (keys.includes(`width` as const) && !o.hasOwnProperty('width')) {
   throw new Error('');
}

Argument of type '"width"' is not assignable to parameter of type 'K'. '"width"' is assignable to the constraint of type 'K', but 'K' could be instantiated with a different subtype of constraint 'keyof Rectangle'.ts(2345)

I was wondering why as const doesn't work here, the problem I am wondering about is that when I or a colleague decide to change or rename properties on the type Rectangle, I would like typescript to warn me when this assert does not longer cover the type. Adding, renaming or subtracting a property on the type will not get catched by this assert.

In a comment you've said:

I am doing it in this way because each property/key could have seperate checks. For example the Rectangle can have an id field and I would also want to assert if that id is of type string and matches a uuid regex

In that case, I'd approach it like this:

  1. Loop through keys checking that they exist

  2. For properties that need additional checks, use if / else if to identify them and apply the extra checks. (I was surprised to find that switch doesn't complain about cases that can never be reached, but if does.)

Something like the following — note that in this example I have a check for a property that doesn't exist on Rectangle , and TypeScript warns me about that. This is to demonstrate your "I am wondering about is that when I or a colleague decide to change or rename properties on the type Rectangle" situation (in this case, let's say Rectangle used to have id but doesn't anymore).

type Rectangle = {
    width: number,
    height: number;
};

const assertObject: (o: unknown) => asserts o is Record<PropertyKey, unknown> = (
    o,
) => {
    if (o === null || typeof o !== `object`) {
//      ^^^^^^^^^^^^^−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−− added
        throw new Error();
    }
};

function assertRectangle<K extends keyof Rectangle>(
    o: unknown,
    ...keys: K[]
): asserts o is Pick<Rectangle, K> {
    assertObject(o);
    for (const key of keys) {
        // Basic check
        if (!o.hasOwnProperty(key)) {
            throw new Error("");
        }
        // Additional per-property checks
        // (I was surprised that `switch` didn't work here to call out property
        // names that aren't on Rectangle like "id" below.)
        if (key === "width" || key === "height") {
            if (typeof o[key] !== "number") {
                throw new Error(`typeof of '${key}' expected to be 'number'`);
            }
        } else if (key === "id") {
//                 ^^^^^^^^^^^^−−−−−−−− causes error because `id` isn't a valid
//                                      Rectangle property (e.g., if you remove
//                                      a property from `Rectangle`, TypeScript
//                                      warns you)
            const id = o[key];
            if (typeof id !== "string" || !id) {
                throw new Error(`'${key}' expected to be non-empty string`);
            }
        }
    }
}

declare let rect1: unknown;
declare let rect2: unknown;
declare let rect3: unknown;
assertRectangle(rect1, "width");
rect1; // <== type is Pick<Rectangle, "width">
assertRectangle(rect2, "height", "width");
rect2; // <== type is Pick<Rectangle, "height" | "width">
assertRectangle(rect3, "height", "width", "not-rectangle-property");
// Error −−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−^

Playground link

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM