I have a function makeMergedState
which takes either an object or an array of type ICustomState
.
The functions contains conditional statements depending on whether the input is a valid ICustomState or ICustomState[]. In case of the input being an invalid object erroneously type casted, I want the function to throw.
This is the test case I want to succeed:
it("throws on invalid input", () => {
expect(() => makeMergedState({ test: "" } as ICustomState)).toThrow();
});
ICustomState is a TypeScript interface containing only optional properties. I can type guard the array with such a function:
const isCustomStateArray = (p: any): p is ICustomState[] => !!p[0];
However, I can't find a way to make an equivalent isCustomState
type guard, and I think this is a limitation of how type guards work with the type system.
According to this GitHub issue, it's possible to work around this limitation with tag types, but I'm unsure how.
Greatly appreciate any suggestion.
EDIT: Codesandbox example
The answer to another question goes over why it's not straightforward to automate the runtime type guarding of compile time interfaces (i.e., type erasure), and what your options are (i.e., code generation as in typescript-is
, classes and decorators as in json2typescript
, or schema objects which can be used to generate both type guards and interfaces, as in io-ts
).
In case it matters, I've translated the code example from that question to your case. This is one possible way to write code that generates both the type guard and the interfaces. Your schema library could look like this:
namespace G {
export type Guard<T> = (x: any) => x is T;
export type Guarded<T extends Guard<any>> = T extends Guard<infer V> ? V : never;
const primitiveGuard = <T>(typeOf: string) => (x: any): x is T => typeof x === typeOf;
export const gString = primitiveGuard<string>("string");
export const gNumber = primitiveGuard<number>("number");
export const gBoolean = primitiveGuard<boolean>("boolean");
export const gNull = (x: any): x is null => x === null;
export const gObject =
<T extends object>(propGuardObj: { [K in keyof T]: Guard<T[K]> }) =>
(x: any): x is T => typeof x === "object" && x !== null &&
(Object.keys(propGuardObj) as Array<keyof T>).
every(k => (k in x) && propGuardObj[k](x[k]));
export const gPartial =
<T extends object>(propGuardObj: { [K in keyof T]: Guard<T[K]> }) =>
(x: any): x is { [K in keyof T]?: T[K] } => typeof x === "object" && x !== null &&
(Object.keys(propGuardObj) as Array<keyof T>).
every(k => !(k in x) || typeof x[k] === "undefined" || propGuardObj[k](x[k]));
export const gArray =
<T>(elemGuard: Guard<T>) => (x: any): x is Array<T> => Array.isArray(x) &&
x.every(el => elemGuard(el));
export const gUnion = <T, U>(tGuard: Guard<T>, uGuard: Guard<U>) =>
(x: any): x is T | U => tGuard(x) || uGuard(x);
export const gIntersection = <T, U>(tGuard: Guard<T>, uGuard: Guard<U>) =>
(x: any): x is T & U => tGuard(x) && uGuard(x);
}
From that we can build your IExample1
guard and interface:
const _isExample1 = G.gObject({
a: G.gNumber,
b: G.gNumber,
c: G.gNumber
});
interface IExample1 extends G.Guarded<typeof _isExample1> { }
const isExample1: G.Guard<IExample1> = _isExample1;
If you look at _isExample1
you can see how it looks sort of like {a: number; b: number; c: number}
and if you inspect IExample1
it will have those properties. Notice the gObject
guard doesn't care about extra properties. A value {a: 1, b: 2, c: 3, d: 4}
will be a valid IExample1
; this is fine because object types in TypeScript are not exact. If you want your type guard to enforce that there are no extra properties, you would change the implementation of gObject
(or make a gExactObject
or something).
Then we build ICustomState
's guard and interface:
const _isCustomState = G.gPartial({
example1: isExample1,
e: G.gString,
f: G.gBoolean
});
interface ICustomState extends G.Guarded<typeof _isCustomState> { }
const isCustomState: G.Guard<ICustomState> = _isCustomState;
Here we are using gPartial
to make the object have only optional properties, as in your question. Notice that the guard for gPartial
checks the candidate object and only rejects an object if the key is present and of the wrong type. If the key is missing or undefined
, that's fine, since that's what an optional property means. And like gObject
, gPartial
doesn't care about extra properties.
When I look at your codesandbox code I see you are returning true
if any of the property keys are present, and false
otherwise, but that's not the right test. The object {}
with no properties would be assignable to an object type with all optional properties, so you don't need any properties to be present. And presence of the key alone doesn't count, since the object {e: 1}
should not be assignable to {e?: string}
. You need to check all the properties that are present in the candidate object, and reject it if any of the properties are of the wrong type.
(Note: if you had an object with some optional and some required properties, you could use an intersection like G.gIntersection(G.gObject({a: G.gString}), G.gObject({b: G.gNumber}))
which would guard for {a: string} & {b?: number}
which is the same as {a: string, b?: number}
.)
Finally your ICustomState[]
guard:
const isCustomStateArray = G.gArray(isCustomState);
Let's test that CustomState
guard to see how it behaves:
function testCustomState(json: string) {
console.log(
json + " " + (isCustomState(JSON.parse(json)) ? "IS" : "is NOT") + " a CustomState"
);
}
testCustomState(JSON.stringify({})); // IS a CustomState
testCustomState(JSON.stringify({ e: "" })); // IS a CustomState
testCustomState(JSON.stringify({ e: 1 })); // is NOT a CustomState
testCustomState(JSON.stringify({ example1: { a: 1, b: 2, c: 3 } })); // IS a CustomState
testCustomState(JSON.stringify({ w: "", f: true })); // IS a CustomState
This is all fine, I think. The only example that failed is {e:1}
, because its e
property is the wrong type (number
instead of string | undefined
).
Anyway, hope this helps; good luck!