Skip to content

#363 Request for comments: Change type casting for settings service

Can be reviewed, but shouldn't be merged yet. NB this is why I upped the approval count.

This is meant as a proposal, that might solve the problem described in the ticket #363 (closed)

interface TypeAssertionMap {
  string: string
  boolean: boolean
}

type TypeAssertions = {
  [key in keyof TypeAssertionMap]: (value: unknown) => value is TypeAssertionMap[key]
}

const typeAssertions: TypeAssertions = {
  string: (value): value is string => typeof value === 'string',
  boolean: (value): value is boolean => typeof value === 'boolean'
}

const get = <Assertion extends keyof TypeAssertionMap>(id: string, type: Assertion): TypeAssertionMap[Assertion] => {
  let returnValue: unknown = getValueFromStore(id)
  const assertion = typeAssertions[type]

  if (!assertion(returnValue)) {
    throw new Error(`Expected type ${type}, got instead ${typeof returnValue}`)
  }
  return returnValue
}

const foo = get('foo', 'string') // has an inferred type of string
const bar = get('bar', 'boolean') // has an inferred type of boolean
const lemon = get('lemon', 'number') // doesn't exist! Gives a type error

What's going on

interface TypeAssertionMap {
  string: string
  boolean: boolean
}

This provides a record of the assertion methods we want to provide, and what types they should correspond to, this is how we know what the return type is when we call the method

type TypeAssertions = {
  [key in keyof TypeAssertionMap]: (value: unknown) => value is TypeAssertionMap[key]
}

const typeAssertions: TypeAssertions = {
  string: (value): value is string => typeof value === 'string',
  boolean: (value): value is boolean => typeof value === 'boolean'
}

This is a mapped type, that says every key with TypeAssertionMap needs to be present, and the value should be a type assertion function, of the value type in TypeAssertionMap

  • If you don't add a key, or add anew key to TypeAssertionMap without adding an assertion function, you'll get a type error
  • If the return type of the function in the assertion, isn't that in the type of TypeAssertionMap, you'll get a type error, i.e.
const typeAssertions: TypeAssertions = {
  boolean: (value): value is FooType => typeof value === 'boolean'
}

will give an error, because it needs to return value is boolean

const get = <Assertion extends keyof TypeAssertionMap>(id: string, type: Assertion): TypeAssertionMap[Assertion] => { ... }

The content of the function isn't important, what's important is the type signature. The second parameter must be a key of TypeAssertionMap (i.e. 'string' or 'number'). The return type is then the value type set in TypeAssertionMap, i.e. 'string' => string, 'number' => number etc.

const foo: string = get('foo', 'string') // Good, no error
const foo: boolean = get('foo', 'string') // Bad! Type error!

Pros

  • We get to keep infered types of get, set, valueChanges
  • We get additional runtime type checking, that has to match with the compile time checking
  • We currently only have two types in use, string and boolean, so it's not complex to implement

Cons

  • Need to provide typeguard functions for each type we want to cast to
  • Still need to know ahead of time, what type a given ID will be, makes updating this difficult (though we had this problem before)
  • Runtime typechecking has slight perf cost (but we're doing that in lots of places anyway)

With regards to the first problem, we could potentially keep both the old type casting, and the type assertions at the same type, via operator overloading, e.g.

class Settings {
  /** @deprecated Use get('id', 'type') instead, adding a new type assertion if necessary */
  public get<Type>(id: string): Type
  public get = <Assertion extends keyof TypeAssertionMap>(id: string, type: Assertion): TypeAssertionMap[Assertion]
  public get(id: string, type?: keyof TypeAssertionMap) {
    // ...
  }

  // Will be flagged as deprecated, but still will work
  const old: string = settings.get<string>('old-id')
  const new: string = settings.get('new-id', 'string')
}

This way, we can continue to ues the function in the old way, but gradually replace with the old way.

Not quite sure about the deprecated warning though

Edited by James Jenkinson

Merge request reports

Loading