statsig-io / js-client

Statsig's SDK for client-side javascript applications.
ISC License
17 stars 11 forks source link

Accept optional type guard in DynamicConfig getter #4

Closed bilalq closed 2 years ago

bilalq commented 2 years ago

The behavior prior to this change was only type-safe for simple primitive types. Complex objects or Arrays of objects would be dangerously type-cast.

This change addresses the issue by accepting an optional type guard function as a third parameter. If it is not supplied, the behavior remains unchanged from prior releases. If it is supplied, the property access is checked against the type guard prior to returning. If the type guard resolves to false, then the default value is returned instead.

In cases where a null or undefined default value is passed and type guard is supplied for a non-nullable type in strict mode, the type should resolve to T | null or T | undefined. Similarly, wider types in the default will resolve to the wider type for the end value. Narrower types will remain as the wider type T.

This allows for type-safe access of dynamic values (assuming the type guard function is well-written).


Here's a minimal playground example showcasing the way the types work for this:

const get = <T, > (
  key: string, 
  defaultValue: T, 
  typeGuard?: (val: unknown) => val is T
): T => {
  const dynamic = JSON.parse(key.length > 1 ? '{"category": "real"}' : '{"other": 10}')
  if (typeGuard) {
    return typeGuard(dynamic) ? dynamic : defaultValue
  }
  return defaultValue
}

interface Thing {
  category: 'real' | 'imaginary'
}
const defaultThing: Thing = { category: 'real' }

const isThing = (obj: any): obj is Thing => {
  return obj?.category === 'real' || obj?.category === 'imaginary'
}

const regularExample = get('key', defaultThing, isThing)
// Type of regularExample is Thing

const nullableExample = get('key', null, isThing)
// Type of nullableExample is Thing | null

const undefinedAbleExample = get('key', undefined, isThing)
// Type of undefinedAbleExample is Thing | undefined

interface NarrowerThing extends Thing {
  category: 'real'
}
const narrowerTypedDefaultThing: NarrowerThing = { category: 'real' }
const narrowerDefaultTypeExample = get('key', narrowerTypedDefaultThing, isThing)
// Type of narrowerDefaultTypeExample is Thing

interface WiderThing {
  category: Thing['category'] | 'transcendent'
}
const widerTypedDefaultThing: WiderThing = { category: 'transcendent' }
const widerDefaultTypeExample = get('key', widerTypedDefaultThing, isThing)
// Type of widerDefaultTypeExample is WiderThing

const otherTypedExample = get('key', { other: 'somethingElse' }, isThing)
// Type Error!
// Argument of type '{ other: string; }' is not assignable to parameter of type 'Thing'.
// Object literal may only specify known properties, and 'other' does not exist in type 'Thing'.

You can examine this example closer in the TypeScript playground at this link.