Home > Mobile >  How to dynamically set property on scope chain in TypeScript?
How to dynamically set property on scope chain in TypeScript?

Time:12-21

I have a nested tree of scopes like this:

const parentScope = {
  data: {
    foo: 'bar'
  }
}
const child1Scope = {
  parent: parentScope,
  data: {
    hello: 'world',
    one: 2
  }
}
const child2Scope = {
  parent: child1Scope,
  data: {
    a: true
  }
}

I would like to use them like this:

setProperty(child2Scope, 'a', false)
setProperty(child2Scope, 'hello', 'there')
setProperty(child2Scope, 'foo', 'baz')

function setProperty(scope, property, value) {
  if (property in scope.data) {
    scope.data[property] = value
  } else if (scope.parent) {
    setProperty(scope.parent, property, value)
  } else {
    throw new Error(`Property not defined on scope`)
  }
}

How can I properly do this in TypeScript? Each scope will have a defined type structured, if that helps.

The definition for my scope object is roughly like this, though I'm still working on getting it working.

Particularly relevant is that the parent in the scope type definition can be unknown, as seen in my most current attempt:

export type ScopeType<
  A extends Scope,
  B extends ScopeType<Scope> | unknown = unknown,
> = {
  data: ScopeTableType[A]
  like: A
  parent?: B
}

Here is my attempt trying to type it:

function setProperty(scope: ScopeType<Scope>, property: keyof ScopeType<Scope>, value: unknown) {
  if (property in scope.data) {
    scope.data[property] = value
  } else if (scope.parent) {
    setProperty(scope.parent, property, value)
  } else {
    throw new Error(`Property not defined on scope`)
  }
}

CodePudding user response:

You will have to traverse a tree, and then bail when you are out of parents. That means this will take some recursive conditional types to pull off.

So given the data:

const parentScope = {
  data: { foo: 'bar' }
}
const child1Scope = {
  parent: parentScope,
  data: { hello: 'world', one: 2 }
}
const child2Scope = {
  parent: child1Scope,
  data: { a: true }
}

We can derive a type to describe that like so:

type Scope = {
  parent?: Scope
  data: Record<string, unknown>
}

Now to make this work:

setProperty(child2Scope, 'foo', 'baz')

We need a generic type that constrains that second property argument, and another type for the value.

Here's one for the keys:

// Get all possible keys in scope
type ScopeKeys<T extends Scope> =
  
  // does this scope have a parent?
  T['parent'] extends Scope 

    // Get a union of the keys at this level and recursivelly
    // get the keys of the parent scope
    ? keyof T['data'] & string | ScopeKeys<T['parent']> 

    // No parent, just return the keys of this level.
    : keyof T['data'] & string 


// test
type K = ScopeKeys<typeof child2Scope>
// type K = "foo" | "hello" | "one" | "a"

This recursive conditional type collects all scopes data keys as a union. It keeps recursing up parent until there is no parent.


Now we need the one for the value:

// Get the value type of a specific key in a scope.
type ScopeValue<T extends Scope, K extends string> =
  
  // Does the data have this key?
  T['data'] extends { [key in K]: unknown }

    // Get the value type of this key
    ? T['data'][K]

    // No key found, so is there a parent scope?
    : T['parent'] extends Scope

      // Ask the parent scope the same question
      ? ScopeValue<T['parent'], K>

      // No parent scope, and we failed to find a match, so return never
      : never

// tests
type V = ScopeValue<typeof child2Scope, 'one'>
// type V = number

This type does something similar to the previous one, alrhough instead of collection results from each level, it's looking for just one. The general flow is:

  1. Does this level have the key?
  2. If yes, return its value
  3. If not, does this level have a parent?
  4. If yes, go to step 1.
  5. If not, return never

Now typing the function is pretty simple:

function setProperty<
  T extends Scope,
  K extends ScopeKeys<T>
>(
  scope: T,
  property: K,
  value: ScopeValue<T, K>
) {
  if (property in scope.data) {
    scope.data[property] = value
  } else if (scope.parent) {
    setProperty(scope.parent, property, value)
  } else {
    throw new Error(`Property not defined on scope`)
  }
}

Which you'd use like so:

setProperty(child2Scope, 'a', false) // fine on 1st level
setProperty(child2Scope, 'a', 123) // error, wrong value type
setProperty(child2Scope, 'b', {} as any) // error, no prop by that name in scope

setProperty(child2Scope, 'hello', 'there') // 2nd level, fine
setProperty(child2Scope, 'hello', 123) // error, wrong value type

setProperty(child2Scope, 'foo', 'baz') // 3nd level, fine
setProperty(child2Scope, 'foo', false) // error, wrong value type

See Playground

  • Related