#Can a generic function be constrained further before usage?

55 messages · Page 1 of 1 (latest)

frozen trench
#

It's quite difficult to explain the goal, so feel free to ask if there's something unclear.
The end goal is that an object's shape will determine what functions are valid for it. I have a working implementation that does not constrain generics, which works okay. However, calling generics in a type safe way is bulky ( foo<typeof myConstraint.shape>({ /* ... */ }) or foo({ unusedValue: myConstraint.shape })). Is it possible to constrain it before the function is even passed to the user?

#

Oops, typo in the playground. Fixed version:

sacred timberBOT
#
lecarbonator#0

Preview:```ts
declare const dataTag: unique symbol

interface DataTag<T> {
[dataTag]?: T
}

// Users can extend it by any means with any type. Short example below.
interface Foo<T extends string> extends DataTag<T> {
dependentValue: T | null
}
function foo<T extends string>(params: Foo<T>)
...```

odd cargo
#

Not exactly sure what your goal is, but here's one potential solution:

sacred timberBOT
#
nonspicyburrito#0

Preview:```ts
declare const dataTag: unique symbol

interface DataTag<T> {
[dataTag]?: T
}

interface Foo<T extends string> extends DataTag<T> {
dependentValue: T | null
}

function foo<T extends string>(params: Foo<T>) {}

type Constrain<TConstrainTo extends string> =
...```

frozen trench
#

so there'd be other cases that are completely valid, like having it be called bar instead of foo

#

the runtime value is accessed from an object that the user created (Record<string, TheGenericFunction>), so maybe there's a way to do it using that record

odd cargo
#

Might be better to showcase how it's actually going to be used then.

frozen trench
#

sure, I'll write something basic with actual values

frozen trench
sacred timberBOT
#
lecarbonator#0

Preview:```ts
import React, {ReactNode} from "react"

// The user creates these React components for reusability
interface FooProps<T extends string> {
derivedValue: T
}

function Foo<T extends string>(props: FooProps<T>) {
// Accessing API through context
// @ts-expect-error
...```

frozen trench
#

as listed in the use case, it's not impossible to work with it right now. I'm just curious if there's a way to let TypeScript handle this instead of having to add additional (unused) runtime values to get the typing right

odd cargo
#

Which parts can you change and which parts you cannot change?

frozen trench
# odd cargo Which parts can you change and which parts you cannot change?

type-wise, basically everything apart from Foo being a function.

  • I can change it so users must have props extend my own (if typing can use it)
  • I can force users to curry the component by passing it through another function (like brandComponent(Foo))
  • I can't change the record structure itself. Once assigned, that's the type I have to work with. I can only narrow it from there.
  • I can't force the names of the record properties. That's up to the user.
  • It's imperative that context is used, so even if an additional prop is added, it would just be unused at runtime
odd cargo
#

What's getApi, how come you can change it if Foo is implemented by user?

frozen trench
# odd cargo What's `getApi`, how come you can change it if `Foo` is implemented by user?

getApi exposes an API that helps with additional typings. The context provider for example accepts additional parameters that is dependent on the api and the props it receives.

the getApi is a created function based on the record that a user gave it. Internally, it creates everything API-related and assigns the components before passing it out again. Perhaps this is the better version to explain:

// const api1 = getApi('')
const { getApi } = createApi({ Foo });
const api1 = getApi('') // contains Foo

Foo is assigned to the API at the very end to allow users to have shorthand components that take over a lot of boilerplate that the API would otherwise expect from them

odd cargo
#

I feel like I'm missing some context, but I don't understand why you must forbid <api2.Foo derivedValue="" />

#

Foo is a component that works for any string derivedValue, so '' works too.

frozen trench
#

example: In Foo, if a button is clicked, it calls api.setValue(props.derivedValue).
That could be, for example, a Radio button. The context API stuff was made before this really showed up as a problem, and ideally you want to ensure that api.value and derivedValue must be compatible

#

right now, it would not warn you at all unless you added your own checks (like the unused api prop or typing the generic component explicitly)

odd cargo
#

Then why is Foo generic?

frozen trench
#

I mean, it's not required to be. If the derived value can be expressed without needing it, that's fine too

sacred timberBOT
#
nonspicyburrito#0

Preview:```ts
import React, {ReactNode} from "react"

interface FooProps<T extends string> {
derivedValue: T
}

const createApi =
<U extends string, P extends object>(options: {
Foo: (props: P & FooProps<U>) => React.JSX.Element
}) =>
<T extends U>(
...```

odd cargo
#

This is what you want if I'm understanding correctly.

frozen trench
frozen trench
#

The derived value seems to work! I assume one of the limitations of this is that the prop is not very flexible (must be called derivedValue, can't be an array of strings etc.), but it's progress!

#

the main problem (from what I can tell) is that in order to restrict the value, you need to know what property must be narrowed in advance. You can't narrow the whole generic or assert that if derivedValue: 'a' | 'b' => It must be FooProps<'a' | 'b'>

odd cargo
#

Should be able to get that to work too.

frozen trench
odd cargo
#

Can you show what your ideal API looks like?

frozen trench
# odd cargo Can you show what your ideal API looks like?

The current progress is that the derived component works, but it's forced to use value: T and cannot be renamed or be a different shape.
That already covers like 90% of use cases, so it's not a big deal if custom shapes require the user to put in more work themselves.

That being said, here's kind of what an ideal API would look like (ignoring typing syntax).
The generics obv don't work when implemented this way, it's just to show what values are and aren't affected by the API value

// Normal components / Generic components that aren't derived. Can be added later since this is trivial
declare function Foo(props: FooProps): JSX.Element;

// Derived component. Props are dependent on the API's value
interface RadioButtonProps<ApiValue extends string> {
  value: ApiValue;
  label: string;
}
const RadioButton = createDerivedComponent(function RadioButton<T extends string>(props: RadioButtonProps<T>) {
  // A button that sets the Api's value on click.
  // The expected behaviour is that `props.value` must satisfy the Api value
  return <></>
})

// A different example for derived component. Also dependent on the API's value
interface SelectProps<ApiValue extends string> {
   options: ApiValue[];
   default?: ApiValue;
   label: string;
}

const Select = createDerivedComponent(function Select<T extends string>(props: SelectProps<T>) {
   // A select menu that sets the Api's value on select.
   // The allowed options are expected to satisfy the Api value.
   return <></>
}

// Record can be freely named.
const { getApi } = createApi({ RadioButton, Menu: SelectMenu })

const api1 = getApi<string>()
<api1.RadioButton value="anything" label="" />
<api1.SelectMenu options={["anything", "works"]} label="" />

const api2 = getApi<'a' | 'b'>()
// value must be 'a' | 'b'
<api2.RadioButton value="a" label="" />
// options must be 'a' | 'b'
<api2.SelectMenu options={["a", "b"]} default="a" label="" />
#

For reference, the planned API currently looks like:

interface RadioButtonProps extends DerivedFieldProps<string> {
  label: string
}

const RadioButton = createDerivedFieldComponent(function Test(
  props: RadioButtonProps,
) {
  const { label, value, api } = props
  return (
    <button type="button" onClick={() => api.setValue(value)}>
      {label}
    </button>
  )
})

<api2.RadioButton value="a" label="Other" />
odd cargo
#

I'm not seeing how it relates to "must be called derivedValue"

#

How would your API look like if you were to allow user to specify a different prop name?

frozen trench
#

to narrow the props to the allowed values, it needs to know which property has to be narrowed

odd cargo
#

Yes and I'm saying it should be possible to allow user to specify the name instead of being hardcoded.

frozen trench
#

well, that derived shape differs not only from createApi to createApi, but it can differ between components as well

#

so that means

  • the keys are dynamic (users can name it whatever, RadioButton, Select, Foo)
  • the components can have any derived shape ( value, options, defaultValue, ideally the user can decide)
  • the API's value has to then influence how the properties are narrowed depending on what properties are derived and in what form ApiValue, ApiValue[]
#

to think that going for a context-based system could make this so complicated ...
The simplest way is still to just do

interface FooGeneric<T> {
   derivedValue: NoInfer<T>;
   // unused by component, but ok for inference
   api: { nested: { location: { here: T }
}

// ...
<api.FooGeneric api={api} value="a" />

which of course just makes the namespace pointless

sacred timberBOT
#
nonspicyburrito#0

Preview:```ts
import React, {ReactNode} from "react"

const createApi =
<P extends object>(options: {
Foo: (props: P) => React.JSX.Element
}) =>
<K extends keyof P, V extends string>(
key: K,
value: V
) => {
const Foo: (
props: P & Record<K, V>
) => React.JSX
...```

odd cargo
#

Well I'm not quite sure what your API wants to look like, but the above example has a user configurable prop name via:

const api2 = getApi('bar', 'a' as 'a' | 'b')
#

Now the bar prop is configured to be 'a' | 'b' and will give a type error if you pass something else like bar="".

frozen trench
frozen trench
odd cargo
#

🤷

#

Maybe one design is to do:

const getApi = createApi({
    RadioButton: {
        component: RadioButton,
        propName: 'value',
    },
    Menu: {
        component: SelectMenu,
        propName: 'selectedValue',
    },
})

const api = getApi<'a' | 'b'>()

// api.RadioButton must have value = 'a' | 'b'
// api.Menu must have selectedValue = 'a' | 'b'
#

Should be pretty doable with the same idea as above.

frozen trench
#

yeah, maybe ... what about cases like options though, where it's modifying the derived value?

#

like api.Menu must have options = ('a' | 'b')[]

frozen trench
odd cargo
#

You'd probably need HKT for that case.

frozen trench
#

yeah, I'll check that out sometime. Fairly sure this is a good start with getting there. Thanks a lot for your support through this!

#

I think this discord server needs posts to be resolved, right? what do I type for that?

odd cargo
#

!resolve, I think.