1

I have an interface V and an object x whose type is unknown. If any property of x is a key of V, I want to throw a runtime error. What is the most TypeScript friendly way of doing this?

Here's a clear example:

interface V {
  a: number;
  b: string;
}

const x = getDataFromRequest();

if (hasInvalidProperties(x)) {
  throw new Error();
}

I know there are a million ways to do this using JavaScript (eg- keeping an array of invalid strings, etc) though I am hoping to leverage the type system to make this check implicit.


EDIT: My concern about using an array of invalid keys is that I don't want to forget to update the array if I change the interface. If I use an array, I need a type-checked way to ensure that it stays consistent with the interface.

Andrew Eisenberg
  • 28,387
  • 9
  • 92
  • 148
  • 3
    Wait, you want this at runtime? The type system has no runtime effects. The interface `V` is completely [erased](https://github.com/Microsoft/TypeScript/wiki/FAQ#what-is-type-erasure) by runtime. So `hasInvalidProperties()` will not be able to consult it. You need to do something like keep an array of invalid strings. – jcalz Oct 18 '19 at 16:16
  • 1
    Typescript is a compile time type checker. It has not runtime effects. You can create a type that does what you want, and will prevent anyone from assigning an invalid value to x in the code explicitly but if x comes from an outside source like an API, you cannot use typescript. You might checkout https://github.com/gcanti/io-ts it seems like it might fit your use case. – richbai90 Oct 18 '19 at 16:29
  • @jcalz, I realize that interfaces are erased at compile time. My concern about keeping an array of invalid keys at runtime is that there are two things I need to keep up to date whenever the interface changes. At the very least, I need the type checker to warn me if the array and interface are out of sync. I'm going to revise my question. – Andrew Eisenberg Oct 18 '19 at 16:44
  • You could probably have a separate custom script for validation (in JS). 1 - read your interface from a file, and store the fields in a variable. This will probably require you to manually parse the file. 2 - have your custom script make a request, then check if data in the result matches the values from the interface file you've parsed. There's no out of the box way of doing this because everything that is typescript related never makes it to the runtime. Other option is probably a plugin – Fabio Lolli Oct 18 '19 at 17:00

3 Answers3

2

Once you accept the sad truth that you'll need to maintain some runtime artifact related to V, since V itself will be erased, and assuming you can't actually use the runtime artifact to define V, the best you can do is have the compiler yell at you if your runtime artifact and V are out of sync. Here's one way to do it:

interface V {
    a: number;
    b: string;
}

const keysOfV = ["a", "b"] as const;
type MutuallyAssignable<T extends U, U extends V, V = T> = void;
// the following line will give an error if keysOfV is wrong
type KeysOfVIsGood = MutuallyAssignable<keyof V, typeof keysOfV[number]>; // okay

Note the as const in the definition of keysOfV. That's a const assertion and it (or something like it) is needed to have the compiler keep track of the literal string elements of keysOfV instead of inferring the correct-but-too-wide type string[].

Then, MutuallyAssignable<T, U> is a type that evaluates to void, but we don't really care about what it evaluates to. What we care about is that T is constrained to U, and U is constrained to T (via a default parameter to sidestep a circular constraint violation). When you use MutuallyAssignable<X, Y> on some types X and Y, you will get a compiler error if the compiler does not recognize that X and Y are mutually assignable.

Then you can go on to define and use your hasInvalidProperties() function however you want, using keysOfV. Perhaps like this:

function hasInvalidProperties(x: object): x is { [K in keyof V]: Record<K, any> }[keyof V] {
    return Object.keys(x).some(k => hasInvalidProperties.badKeySet.has(k));
}
hasInvalidProperties.badKeySet = new Set(keysOfV) as Set<string>;

/// test

function getDataFromRequest(): object {
    return Math.random() < 0.5 ? { c: "okay" } : { a: "bad" };
}
const x = getDataFromRequest();
if (hasInvalidProperties(x)) {
    console.log("not okay");
    throw new Error();
}
console.log("okay");

The main event though is what happens when keysOfV is wrong. Here's what happens when it's missing an entry:

const keysOfV = ["a"] as const;
type MutuallyAssignable<T extends U, U extends V, V = T> = void;
// the following line will give an error if keysOfV is wrong
type KeysOfVIsGood = MutuallyAssignable<keyof V, typeof keysOfV[number]>; // error!
// "b" is not assignable to "a" ------> ~~~~~~~

And here's what happens when it has an extra entry:

const keysOfV = ["a", "b", "c"] as const;
type MutuallyAssignable<T extends U, U extends V, V = T> = void;
// the following line will give an error if keysOfV is wrong
type KeysOfVIsGood = MutuallyAssignable<keyof V, typeof keysOfV[number]>; // error!
// "c" is not assignable to "a" | "b" ---------> ~~~~~~~~~~~~~~~~~~~~~~

Hopefully those error messages and locations are descriptive enough for you to understand how to fix it when V changes.


Okay, hope that helps; good luck!

Link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • This is good. Thanks. Instead of doing this: `const keysOfV = ["a", "b"] as const;` Could I create a class that implements `V` `class VImpl implements V { ... }`. Then I can do `Object.keys(Vimpl)` to get all the invalid properties. (I need to implement each field appropriately.) At least I get a compile warning if any field is incorrect. I'm not sure which I like better. – Andrew Eisenberg Oct 18 '19 at 21:45
  • In the end, I went with the class approach, but it is based on your approach. Thanks. – Andrew Eisenberg Oct 19 '19 at 21:21
1

If you're willing to use transformers, you could get the list of properties on an interface at compile time, which would then resolve to a runtime list of strings. Because it relies on a compiler plugin (written in Typescript), it isn't a native TypeScript solution, but it would keep you from having to duplicate data from an interface into a list of strings.

See details on this answer.

Jeff Bowman
  • 90,959
  • 16
  • 217
  • 251
  • I see...interesting approach, but concerned that transformers would wreak havoc on our build process, especially since it is not really a public API. – Andrew Eisenberg Oct 18 '19 at 17:44
0

There is no way to do this with typescript, because by the time your code gets run, there is no longer any knowledge of your types. The only way to do this is with other tools.

One system we use is json-schema. We write schemas for everything going in and out of APIs, and we use tools to automatically convert JSON-Schema into Typescript types. We don't do the reverse because json-schema is more expressive than typescript.

This way we get both runtime validation and static typing using 1 canonical source.

Evert
  • 93,428
  • 18
  • 118
  • 189
  • Not a big fan of JSON-schema. For anything that is large enough, the schemas are really hard to read and almost as hard to write. And the code generation I've seen often duplicates interfaces and enums. IMO, JSON schema is great for machine-machine communication, but not human-machine communication. – Andrew Eisenberg Oct 18 '19 at 17:33
  • 1
    @AndrewEisenberg, well I didn't really mean to specifically suggest this. The main point is that you need other tools to achieve this. JSON-Schema is just an obvious choice here because it's widespread. – Evert Oct 18 '19 at 17:35