13

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.

Matt Croak
  • 2,788
  • 2
  • 17
  • 35
Felix Geisendörfer
  • 2,902
  • 5
  • 27
  • 36
  • 4
    `return data as A;` – hoangdv Nov 14 '19 at 16:25
  • If you decide to use the custom solution let me know if you have any issues with it, it was written in about 5 mins just for this answer so it's not exactly battle tested, but i can work on it if someone actually wants to use it :) – Titian Cernicova-Dragomir Nov 14 '19 at 16:51
  • @TitianCernicova-Dragomir The types in my app are more complicated, so I'm not sure if this approach will "scale", but I'll give it a try and let you know what problems I run into : ). – Felix Geisendörfer Nov 14 '19 at 17:01
  • @FelixGeisendörfer I'm sure things are more complicated, I was thinking of that when I made it composable, as for interface `B`. But yeah, it depends on what your app is. You could go the other way.. define the validation object and derive the interface from it, I have a talk about something similar: https://www.youtube.com/watch?v=wNsKJMSqtAk&list=PL701JjUqw62nHoRho4RJ3KJSLCRG08r1Z&index=3 – Titian Cernicova-Dragomir Nov 14 '19 at 17:09

3 Answers3

9

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

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • 2
    Nice! Having to provide an example value is a little annoying in terms of boilerplate, but the solution seems to be robust! Thanks for the idea. – Felix Geisendörfer Nov 14 '19 at 16:51
6

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')
JulianG
  • 4,265
  • 5
  • 23
  • 24
  • I have to say as soon as you cast to `any` is sure feels like a problem waiting to happen. You could easily mess up a condition and have the compiler carry on happily. – Gezim Oct 14 '21 at 22:17
1

@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

A-S
  • 2,547
  • 2
  • 27
  • 37