Typescript narrowing of keys in objects when passed to function
Asked Answered
E

5

24

Why does TypeScript not apply type narrowing on members of objects to the object type itself, such that it can be passed to another function that expects a narrowed type? How can this be fixed/circumvented without loosing typesafety?

Minimal example:

type A = { key: string | null};
type B = {key: string};

function func(a: B) {};

const a: A = {key:'abcd'};
if(typeof(a.key)==='string') {
  a.key // has (narrowed) type 'string'
  func(a); // still does not work
}

The error message is: Types of property 'key' are incompatible. Type 'string | null' is not assignable to type 'string'.

Playground-Link

Everett answered 13/9, 2019 at 18:49 Comment(2)
remove not !.Storied
@OrkhanAlikhanov Sorry, I fixed the example.Everett
Q
27

This is ultimately a design limitation of TypeScript. It does not use type guards on an object's properties to narrow the object itself, except in the particular case where the object is a discriminated union type... and in your case, A isn't a union at all, let alone a discriminated one.

The way I'd do this for your case is to introduce a user-defined type guard function which explicitly performs the narrowing you expect: you pass in an object with a key property, and return true or false depending on whether or not this object is a valid B:

const isB = (x: { key: any }): x is B => typeof x.key === "string";

Then you use it:

if (isB(a)) {
  func(a); // okay
}

This is (give or take a function call) essentially the same logic as your code, but the compiler recognizes your intent now. Okay, hope that helps; good luck!

Link to code

Qianaqibla answered 13/9, 2019 at 19:16 Comment(2)
I feel like that limitation needs to be documented. Instead of leaving it to us to figure out.Storied
@OrkhanAlikhanov: it is, github.com/microsoft/TypeScript/blob/master/doc/… : "Note that type guards affect types of variables and parameters only and have no effect on members of objects such as properties. "Population
S
4

use type guards : https://www.typescriptlang.org/docs/handbook/advanced-types.html#typeof-type-guards

type A = { key: string | null};
type B = {key: string};

function func(a: B) {};

const a: A = {key:'abcd'};

const isB = (foo: A | B): foo is B => typeof a.key === "string";
if(isB(a)) {
  a.key
  func(a);
}

TS Playground

Soar answered 13/9, 2019 at 19:16 Comment(0)
F
2

I was curious and found the way how to narrow a property of an object. It doesn't narrow to exactly to the type B, however Typescript is a language with structural typing so the solution works like a charm.

const isPropString = <P extends string, T extends Record<P, T[P]>>(
    prop: P,
    object: T,
): object is T & Record<P, string> => {
    return typeof object[prop] === 'string';
};
Florance answered 4/6, 2020 at 16:45 Comment(0)
P
0

What if you wanted to narrow from any property of an object based off a given type? @gwin-pin had the starter, but here it is:

const isValidPropType = <T extends Record<P, T[P]>, P extends keyof T, Z extends T[P]>(
  object: T,
  prop: P,
  typeofParam: Z
): object is T & Record<P, Z> => {
  return typeof object[prop] === typeof typeofParam;
};

TS Playground using the original question.

Pga answered 9/2, 2022 at 23:52 Comment(0)
T
0

Here's a utility that will allow you to guard against key of an object.

// Utility type to exclude specific types from a union
type ExcludeFromUnion<T, U> = T extends U ? never : T;

// Generic type that excludes a specified type U from the properties of T
type ExcludeTypeProperties<T, U> = {
  [K in keyof T]: ExcludeFromUnion<T[K], U>;
};

/**
 * Example usage:
 * type  Address = { postcode: string | null | undefined };
 * type Book = { iban: string | null | undefined };
 * const address = { postcode: "abcd" } as Address;
 * const book = { iban: "1234" } as Book;
 *
 * if (guardObjectProperty(address, "postcode", null)) {
 *  type A = typeof address; // { postcode?: string | undefined; }
 * }
 *
 * if (guardObjectProperty(book, "iban", undefined)) {
 *  type B = typeof book; // { iban: string | null; }
 * }
 * }
 *
 * if (guardObjectProperty(address, "postcode", "")) {
 *  type C = typeof address; // { postcode?:  null | undefined; }
 * }
 *
 * @param object
 * @param key
 * @param includeType
 * @returns
 */
export const guardObjectProperty = <T, K extends keyof T, U>(
  object: T,
  key: K,
  excludeType: U
): object is ExcludeTypeProperties<T, U> => 
  typeof object[key] !== typeof excludeType;

How do we use it?

type Address = { postcode?: string | null | undefined };
type Book = { iban: string | null | undefined };
const address = { postcode: "abcd" } as Address;
const book = { iban: "1234" } as Book;
if (guardObjectProperty(address, "postcode", null)) {
  type A = typeof address; // { postcode?: string | undefined; }
}

if (guardObjectProperty(book, "iban", undefined)) {
  type B = typeof book; // { iban: string | null; }
}

if (guardObjectProperty(address, "postcode", "")) {
  type C = typeof address; // { postcode?:  null | undefined; }
}

Note: if you make your key?: optional and then attempt to remove undefined it won't let you.

  if (guardObjectProperty(address, "postcode", undefined)) {
    type A = typeof address; // { postcode?: string | null | undefined; }
  }
Tuneful answered 12/12, 2023 at 22:49 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.