Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can TypeScript infer the properties of an object?

I'm attempting to define a user-defined type guard to verify if a variable of unknown type has a data property. My code is defined below.

type DataObject = {
  data: string
};

function hasDataProperty(myObject: unknown): myObject is DataObject {
    return typeof myObject === "object" &&
        !!myObject &&
        typeof myObject.data === "string";
}

Try it on the Typescript Playground.

If you open the playground, you'll see an error on line 8 indicating Property 'data' does not exist on type 'object'., which makes sense, because I haven't verified that the data property exists on myObject.

I've tried using both "data" in myObject and myObject.hasOwnProperty("data") to check if the property exists, but neither seem to affect TypeScript's inferred type of myObject to include data, it is still a plain object type.

I could change the function signature to hasDataProperty(myObject: any) or use a type assertion to change the type from unknown, but both of those options ignore the object's actual properties, which could result in bugs in the type guard's logic.

Is there a way to determine if myObject has a data property without the use of type assertions or any?

like image 601
Adam Avatar asked Oct 25 '25 02:10

Adam


1 Answers

The problem you have is that the bit typeof myObject === "object" tells the compiler that the myObject instance is effectively an Object. So TypeScript assumes in the rest of the expression that myObject is of type {}, which obviously is not.

I would let go of unknown, in this method, and apply KISS:

function isDataObject(myObject: any): myObject is DataObject {
    return myObject && typeof myObject.data === "string";
}

Edit after extensive discussions with zerkms and Adam. Well, I'm quite stubborn, so I continued picking on zerkms solutions. Turns out there could be a safer way to build Type Guards, and I'm going to propose it in the following.

My idea is to use the compile time checks of the compiler to check a "blueprint" of the type guard. I'm going to define a ValidatorDefinition as in the following:

export type ValidatorTypes =
  | "string"
  | "boolean"
  | "number"
  | "array"
  | "string?"
  | "boolean?"
  | "number?";

export type ValidatorDefinition<T> = {
  [key in keyof T]: T[key] extends string
    ? "string" | "string?"
    : T[key] extends number
    ? "number" | "number?"
    : T[key] extends boolean
    ? "boolean" | "boolean?"
    : (T[key] extends Array<infer TArrayItem>
        ? Array<ValidatorDefinition<TArrayItem>>
        : ValidatorTypes);
};

So each property of a simple type must be associated to a string defining exactly the kind of validation.
Then, I'm going to build a TypeGuard factory:

function typeGuardFactory<T>(
  reference: ValidatorDefinition<T>
): (value: any) => value is T {
  const validators: ((propertyValue: any) => boolean)[] = Object.keys(
    reference
  ).map(key => {
    const referenceValue = (<any>reference)[key];
    switch (referenceValue) {
      case "string":
        return v => typeof v[key] === "string";
      case "boolean":
        return v => typeof v[key] === "boolean";
      case "number":
        return v => typeof v[key] === "number";
      case "string?":
        return v => v[key] == null || typeof v[key] === "string";
      case "boolean?":
        return v => v[key] == null || typeof v[key] === "boolean";
      case "number?":
        return v => v[key] == null || typeof v[key] === "number";
      default:
        // we are not accepting null/undefined for empty array... Should decide how to
        // handle/configure the specific case
        if (Array.isArray(referenceValue)) {
          const arrayItemValidator = typeGuardFactory<any>(referenceValue[0]);
          return v => Array.isArray(v[key]) && v[key].every(arrayItemValidator);
        }
        // TODO: handle default case
        return _v => false;
    }
  });
  return (value: T): value is T =>
    (value && validators.every(validator => validator(value))) || false;
}

Example usage:

type DataObject = {
  data: string
};
const hasDataObject = typeGuardFactory<DataObject>({ data: "string" });

The nice thing is that if you misspell the definition of the Validator, you'll get compile time errors.

Some simple unit testing code:

const validatorForDataObject = typeGuardFactory({ data: "string" });

const testCases: { value: any; expected: boolean }[] = [
  { value: null, expected: false },
  { value: undefined, expected: false },
  { value: { data: 0 }, expected: false },
  { value: { data: "0" }, expected: true }
];

testCases.forEach(testCase => {
  const testResult = validatorForDataObject(testCase.value);
  if (testResult === testCase.expected) {
    console.info(`Success (with value ${testResult})`, testCase.value);
  } else {
    console.error(`Fail (with value ${testResult})`, testCase.value);
  }
});

interface AnotherType {
  stringProp: string;
  numberProp: number;
  booleanProp: boolean;
  nullableStringProp?: string;
  nullableNumberProp?: number;
  nullableBooleanProp?: boolean;
  arrayProp: {
    data: string;
  }[];
}

const validatorForAnotherType = typeGuardFactory<AnotherType>({
  stringProp: "string",
  numberProp: "number",
  booleanProp: "boolean",
  nullableStringProp: "string?",
  nullableNumberProp: "number?",
  nullableBooleanProp: "boolean?",
  arrayProp: [
    {
      data: "string"
    }
  ]
});

const testCases2: { value: any; expected: boolean }[] = [
  { value: null, expected: false },
  { value: undefined, expected: false },
  { value: { data: 0 }, expected: false },
  {
    value: {
      stringProp: "string",
      numberProp: 1,
      booleanProp: true,
      nullableStringProp: "string",
      nullableNumberProp: 1,
      nullableBooleanProp: false,
      arrayProp: [{ data: "" }]
    },
    expected: true
  },
  {
    value: {
      stringProp: "string",
      numberProp: 1,
      booleanProp: true,
      nullableStringProp: null,
      nullableNumberProp: null,
      nullableBooleanProp: null,
      arrayProp: []
    },
    expected: true
  },
  {
    value: {
      stringProp: "string",
      numberProp: 1,
      booleanProp: true,
      nullableStringProp: "string",
      nullableNumberProp: null,
      nullableBooleanProp: null,
      arrayProp: [{ data: "" }, { data: "" }, { data: 0 }]
    },
    expected: false
  },
  {
    value: {
      stringProp: "string",
      numberProp: 1,
      booleanProp: true,
      nullableStringProp: "string",
      nullableNumberProp: null,
      nullableBooleanProp: null,
      arrayProp: [{ data: "" }, { data: "" }, { data: 0 }]
    },
    expected: false
  },
  {
    value: {
      stringProp: null,
      numberProp: 1,
      booleanProp: true,
      nullableStringProp: null,
      nullableNumberProp: null,
      nullableBooleanProp: null,
      arrayProp: []
    },
    expected: false
  }
];

testCases2.forEach(testCase => {
  const testResult = validatorForAnotherType(testCase.value);
  if (testResult === testCase.expected) {
    console.info(`Success (with value ${testResult})`, testCase.value);
  } else {
    console.error(`Fail (with value ${testResult})`, testCase.value);
  }
});

The funny thing is that, still, the validation code makes plenty of use of any, but everything else in the porcelain world outside the guard seems pretty solid. It's just a sketch, but I think it shows some potential.

The full snippet.

like image 145
A. Chiesa Avatar answered Oct 26 '25 17:10

A. Chiesa