Reputation: 3042
I'd like to write a function asA
that takes a parameter of type unknown
and returns it as a specific interface type A
, or throws an error if the parameter doesn't match the interface type A
.
The solution is supposed to be robust. I.e. if add a new field to my interface type A
, the compiler should complain about my function missing a check for the new field until I fix it.
Below is an example of such a function asA
, but it doesn't work. The compiler says:
Element implicitly has an 'any' type because expression of type '"a"' can't be used to index type '{}'. Property 'a' does not exist on type '{}'.(7053)
interface A {
a: string
}
function asA(data:unknown): A {
if (typeof data === 'object' && data !== null) {
if ('a' in data && typeof data['a'] === 'string') {
return data;
}
}
throw new Error('data is not an A');
}
let data:unknown = JSON.parse('{"a": "yes"}');
let a = asA(data);
How can I write a function asA
as outlined above?
I'm fine with using typecasts, e.g. (data as any)['a']
, as long as there are no silent failures when new fields are added to A
.
Upvotes: 15
Views: 39066
Reputation: 3125
@JulianG's answer is good, but as @Gezim mentioned - using 'any' defeats the whole purpose.
I've solved it with another function that uses "user-defined type guards" to assert the key's existence. This also allows using the dot-notaions.
function doesKeysExist<T extends string | number | symbol>(input: object, keyName: T | readonly T[]): input is { [key in T]: unknown} {
let keyNameArray = Array.isArray(keyName) ? keyName : [keyName];
let doesAllKeysExist = true;
keyNameArray.forEach(aKeyName => {
if(!(aKeyName in input)) doesAllKeysExist = false;
});
return doesAllKeysExist;
}
It can be used like this:
doesKeysExist(data, 'specificKey')
Or like this:
doesKeysExist(data, ['specificKey_1','specificKey_2'])
And here's the entire thing together:
interface IObjectWithSpecificKey {
specificKey: string
}
function doesKeysExist<T extends string | number | symbol>(input: object, keyName: T | readonly T[]): input is { [key in T]: unknown} {
let keyNameArray = Array.isArray(keyName) ? keyName : [keyName];
let doesAllKeysExist = true;
keyNameArray.forEach(aKeyName => {
if(!(aKeyName in input)) doesAllKeysExist = false;
});
return doesAllKeysExist;
}
function assertIsObjectWithA(data: unknown): data is IObjectWithSpecificKey {
const isA = Boolean((typeof data === 'object') && data != null && doesKeysExist(data, 'specificKey') && typeof data.specificKey === 'string');
return isA;
}
let data: unknown = JSON.parse('{"a": "yes"}');
if (assertIsObjectWithA(data)) { // returns true
console.log(data.specificKey) // within the conditional data is of type IObjectWithSpecificKey
}
console.log(assertIsObjectWithA(null))
console.log(assertIsObjectWithA(undefined))
console.log(assertIsObjectWithA({}))
console.log(assertIsObjectWithA([]))
console.log(assertIsObjectWithA({b: 'no'}))
console.log(assertIsObjectWithA('no'))
console.log(assertIsObjectWithA(12345))
console.log(assertIsObjectWithA({specificKey: 1}))
console.log(assertIsObjectWithA({specificKey: '1'}))
Playground link: here
Upvotes: 1
Reputation: 4765
Aside of libraries like ts-json-validator you can use "user-defined type guards" but it may become a bit verbose doing this for many types.
With type guards you can do something like this. Note that the function I wrote returns true or false, but its return type is annotated as data is A
.
interface A {
a: string
}
function assertIsA(data: unknown): data is A {
const isA = (typeof data === 'object') && ('a' in (data as any) && typeof (data as any)['a'] === 'string')
if (isA === false)
throw new Error('data is not an A');
return isA
}
let data: unknown = JSON.parse('{"a": "yes"}');
if (assertIsA(data)) { // returns true
console.log(data.a) // within the conditional data is of type A
}
// all of these throw
console.log(assertIsA(null))
console.log(assertIsA(undefined))
console.log(assertIsA({}))
console.log(assertIsA([]))
console.log(assertIsA({b: 'no'}))
console.log(assertIsA('no'))
console.log(assertIsA(12345))
If you don't need to throw the whole thing can be reduced to one line:
function assertIsA(data: unknown): data is A {
return (typeof data === 'object') && ('a' in (data as any) && typeof (data as any)['a'] === 'string')
}
or
const assertIsA = (data: unknown): data is A => (typeof data === 'object') && ('a' in (data as any) && typeof (data as any)['a'] === 'string')
Upvotes: 6
Reputation: 249466
You can use an existing solution such as typescript-is
, although that may require you switch to ttypescript
(a custom build of the compiler that allows plugins)
If you want a custom solution, we can build one in plain TS. First the requirements:
The last requirement can be satisfied by having an object with the same keys as A
, with all keys required and the value being the type of the property. The type of such an object would be Record<keyof A, Types>
. This object can then be used as the source for the validations, and we can take each key and validate it's specified type:
interface A {
a: string
}
type Types = "string" | "number" | "boolean";
function asA(data: unknown): A {
const keyValidators: Record<keyof A, Types> = {
a: "string"
}
if (typeof data === 'object' && data !== null) {
let maybeA = data as A
for (const key of Object.keys(keyValidators) as Array<keyof A>) {
if (typeof maybeA[key] !== keyValidators[key]) {
throw new Error('data is not an A');
}
}
return maybeA;
}
throw new Error('data is not an A');
}
let data: unknown = JSON.parse('{"a": "yes"}');
let a = asA(data);
We could go further, and make a generic factory function that can validate for any object type and we can also allow some extra things, like specifying a function, or allowing optional properties:
interface A {
a: string
opt?: string
// b: number // error if you add b
}
function asOptional<T>(as: (s: unknown, errMsg?: string) => T) {
return function (s: unknown, errMsg?: string): T | undefined {
if (s === undefined) return s;
return as(s);
}
}
function asString(s: unknown, errMsg: string = ""): string {
if (typeof s === "string") return s as string
throw new Error(`${errMsg} '${s} is not a string`)
}
function asNumber(s: unknown, errMsg?: string): number {
if (typeof s === "number") return s as number;
throw new Error(`${errMsg} '${s} is not a string`)
}
type KeyValidators<T> = {
[P in keyof T]-?: (s: unknown, errMsg?: string) => T[P]
}
function asFactory<T extends object>(keyValidators:KeyValidators<T>) {
return function (data: unknown, errMsg: string = ""): T {
console.log(data);
if (typeof data === 'object' && data !== null) {
let maybeT = data as T
for (const key of Object.keys(keyValidators) as Array<keyof T>) {
keyValidators[key](maybeT[key], errMsg + key + ":");
}
return maybeT;
}
throw new Error(errMsg + 'data is not an A');
}
}
let data: unknown = JSON.parse('{"a": "yes"}');
const asA = asFactory<A>({
a: asString,
opt: asOptional(asString)
/// b: asNumber
})
let a = asA(data);
interface B {
a: A
}
const asB = asFactory<B>({
a: asA
})
let data2: unknown = JSON.parse('{ "a": {"a": "yes"} }');
let b = asB(data2);
let berr = asB(data);
Upvotes: 11