Felix Geisendörfer
Felix Geisendörfer

Reputation: 3042

Typescript convert unknown type to interface type

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

Answers (3)

A-S
A-S

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

JulianG
JulianG

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))

try it in the playground

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

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

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:

  • Validate that a property is of a specific type
  • Ensure that new fields are validated.

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);

Play

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);

Playground Link

Upvotes: 11

Related Questions