Abstract TypeScript property type guarding logic in a typesafe way

39 Views Asked by At

I want to create TypeScript functions that safely check if an object's property has a certain type. I created a function called guardKey that confirms the object has a certain key. I use guardKey to define two functions that check different property types, guardString and guardNumber:

function hasKey<Data extends Object, Key extends string> (data: Data, key: Key): data is Data & Record<Key, unknown> {
  return key in data
}

function guardKey <Data extends Object, Key extends string> (props: {
  data: Data
  key: Key
}): Data & Record<Key, unknown> {
  if (!hasKey(props.data, props.key)) {
    throw new Error(`There is no ${props.key}`)
  }
  return props.data
}

function guardString (props: {
  data: object
  key: string
}): string {
  const keyed = guardKey({ data: props.data, key: props.key })
  const value = keyed[props.key]
  if (typeof value !== 'string') {
    const message = `${props.key} is not a string`
    throw new Error(message)
  }
  return value
}

function guardNumber (props: {
  data: object
  key: string
}): number {
  const keyed = guardKey({ data: props.data, key: props.key })
  const value = keyed[props.key]
  if (typeof value !== 'number') {
    const message = `${props.key} is not a number`
    throw new Error(message)
  }
  return value
}

guardString and guardNumber share a lot of code. I want to abstract the logic in these two functions into a shared function that can be used with other native types in a fully type safe way. I tried making a function that takes a custom type guard callback, but TypeScript does not throw errors when the callback logic is incorrect:

function guardProperty <T> (props: {
  assert: (value: unknown) => value is T
  data: object
  key: string
  label: string
}): T {
  const keyed = guardKey({ data: props.data, key: props.key })
  const value = keyed[props.key]
  if (!props.assert(value)) {
    const message = `${props.key} is not a ${props.label}`
    throw new Error(message)
  }
  return value
}

const n = guardProperty<number>({
  // This does not actually check for numbers, but there is no error
  assert: (value: unknown): value is number => typeof value === 'string',
  data: { message: 'hello' },
  key: 'message',
  label: 'number'
})

I don't have that problem if I use incorrect logic in the versions that separate the logic:

function guardNumber (props: {
  data: object
  key: string
}): number {
  const keyed = guardKey({ data: props.data, key: props.key })
  const value = keyed[props.key]
  if (typeof value !== 'string') {
    const message = `${props.key} is not a number`
    throw new Error(message)
  }
  // This correctly throws the error "Type 'string' is not assignable to type 'number'."
  return value 
}

How can I abstract the logic in guardNumber and guardString without decreasing type safety? If custom type guards don't work, what is the type safe way to abstract the logic in those two functions so my code stays DRY?

0

There are 0 best solutions below