Cross-interface type guards

Viewed 50

Say a schema object validates a data object.

Is there any kind of dark magic that would allow us to narrow down the type of a data property after a schema-based type guard?

Snippets are worth a thousand words:

function validate(data: Data, schema: Schema) {}

interface Data {
  [key: string]: number | string | undefined;
}

interface Schema {
  properties: {
    [key: string]: { type: "number" | "string" } | undefined;
  };
}

// Just for demo purposes: we don't know its exact shape until run time.
const schema: Schema = {
  properties: {
    firstName: {
      type: "string",
    },
  },
};

// Idem.
const data: Data = {
  firstName: "John",
};

validate(data, schema); // What kind of magic could happen here...

if (schema.properties.firstName?.type === "string") {
  const firstName: string = data.firstName; // ...so that there's no error here?
}

Playground

I'm thinking about turning Schema into a class with a custom getter function that would rely on the asserts keyword to type the data property accordingly. (1) I'm not sure it's feasible and (2) there may be a better way.

Any idea?

1 Answers

Unfortunately TypeScript's type system isn't expressive enough to represent an arbitrary correlation between the properties of schema and those of data. It would require something like correlated unions as described in microsoft/TypeScript#30581, as well as "infinite" unions of every possible object type, which would be like existential types as requested in microsoft/TypeScript#14466... but TypeScript doesn't directly support either of these.

I'd say you need to refactor into something that encapsulates both schema and data in a single object, which exposes some method to get a discriminated union of pairs of schema type and property value. Possibly like this:

function validate(data: Data, schema: Schema) {
  // do validation, and then
  return function prop(k: string) {
    return { type: schema.properties[k]?.type, data: data[k] } as
      { type: "number", data: number } |
      { type: "string", data: string } |
      { type: undefined, data: undefined }
  }
}

So, when you call validate(data, schema), you get a validation function:

const vf = validate(data, schema);

And this function can be called for any given property:

const firstNameProp = vf("firstname");

Which is then a discriminated union of the type/data values:

/* const firstNameProp: {
    type: "number";
    data: number;
} | {
    type: "string";
    data: string;
} | {
    type: undefined;
    data: undefined;
} */

Which can be inspected the way you want:

if (firstNameProp.type === "string") {
  const firstName: string = firstNameProp.data;
} else {
  firstNameProp.data // number | undefined now
}

It's not great, but it's at least possible to represent in the type system.

Playground link to code

Related