Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript convert unknown type to interface type

Tags:

typescript

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.

like image 660
Felix Geisendörfer Avatar asked Nov 14 '19 16:11

Felix Geisendörfer


People also ask

How do you handle unknown types in typescript?

In Typescript, any value can be assigned to unknown, but without a type assertion, unknown can't be assigned to anything but itself and any. Similarly, no operations on an unknown are allowed without first asserting or restricting it down to a more precise type.

How do you cast an unknown string?

// Example to convert unknown type to string: using String Constructor let random: unknown = 'Hello World! '; let stringValue: string = String(random); Caveat: Be careful when using String constructor, as anything will be converted into a string, whether it is a string, a boolean, a number, or even a function!

Should I use unknown in typescript?

unknown is the type-safe counterpart of any . Anything is assignable to unknown , but unknown isn't assignable to anything but itself and any without a type assertion or a control flow based narrowing. Likewise, no operations are permitted on an unknown without first asserting or narrowing to a more specific type.

What is never type in typescript?

Never is a new type in TypeScript that denotes values that will never be encountered. Example 1: The null can be assigned to void but null cannot be assigned to never type variables, nor can any other type be assigned including any.


2 Answers

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

like image 89
Titian Cernicova-Dragomir Avatar answered Sep 22 '22 21:09

Titian Cernicova-Dragomir


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')
like image 38
JulianG Avatar answered Sep 20 '22 21:09

JulianG