#TypeScript narrowing struggles (newbie!)

37 messages · Page 1 of 1 (latest)

weak aspen
#

Hi, I'm new to TypeScript and am trying to wrap my head around something 🤪

I'm struggling to get TS to auto-complete on some type specific properties, such as species of Animal and taste of Food.

I don't want to add a property to my data objects as a discriminated union, if possible, and I want to avoid type assertions.

I have the following types:

import { z } from 'zod'

export interface FormStep<T extends keyof AllFormData> {
    stepName: T
    onSubmit: (stepName: T, data: AllFormData[T]) => void
}

export type AllFormData = {
    animal: Animal
    food: Food
}

export const animalSchema = z.object({
    name: z.string(),
    age: z.number().min(1, 'Age must be at least 1'),
    species: z.string().min(1, 'You must select a species'),
})

export type Animal = z.infer<typeof animalSchema>

export const foodSchema = z.object({
    name: z.string(),
    amount: z.number().min(1, 'Amount must be at least 1'),
    taste: z.string().min(1, 'You must select a taste score'),
})

export type Food = z.infer<typeof foodSchema>
#

I am using React, the relevant code is as follows:

//App.tsx
export default function App() {
    const [stepData, setStepData] = useState<Partial<AllFormData>>({})

    const mergeStepData = useCallback(
        <T extends keyof AllFormData>(stepName: T, data: AllFormData[T]) => {
            if (stepName === 'animal') {
               // I only get auto-complete on .name here, none of the other Animal specific properties
            } else if (stepName === 'food') {
               // I only get auto-complete on .name here, none of the other Food specific properties               
            }
            setStepData((prev) => ({ ...prev, ...data }))
        },
        []
    )

    return (
        <div>
            <AnimalStep onSubmit={mergeStepData} stepName="animal" />
            <FoodStep onSubmit={mergeStepData} stepName="food" />
        </div>
    )
}

//AnimalStep.tsx
export default function AnimalStep({ stepName, onSubmit }: FormStep<'animal'>) {
   function onSubmit (data:Animal) {
        onSubmit(stepName, data);
    }

Can anyone point me to how I can make it so TypeScript knows what type it is dealing with in mergeStepData without me having to explicitly tell it using as?

Shouldn't the 1:1 linking of stepName and it's data type be enough, or am I missing something?

Thanks 👍

tight cairn
#

i haven't looked closely yet, but there's a reason discriminated unions look the way they do, and i think you may need to do this:

I don't want to add a property to my data objects as a discriminated union

#

the computed type for Animal looks like this:

type Animal = {
  name: string
  age: number
  species: string
}

and Food looks like this:

type Food = {
  name: string
  amount: number
  taste: string
}

there's nothing that makes those two types mutually-distinct. given a value like this:

const chicken = {
  name: 'chicken',
  age: 8,
  species: 'chicken',
  amount: 1,
  taste: 'like chicken',
}

is it an Animal or a Food? (the answer is that it's both)

#

the specific problem within your useCallback callback is that T may be instantiated as 'animal' | 'food' (in which case data will also be a union, and its value not necessarily correlated with the value of stepName)

i think there's a way to rewrite just this function to work more like you want though (it needs to accept its args as a concrete discriminated union rather than parameterized stuff). gimme a sec and i'll send an example

#

i think this function better encodes your intent there:

(...[stepName, data]: ['animal', Animal] | ['food', Food]) => {
  if (stepName === 'animal') {
    data // typed as `Animal`
  } else if (stepName === 'food') {
    data // typed as `Food`
  }
  setStepData((prev) => ({ ...prev, ...data }))
}
#

the parameter list itself is now discriminated by the first argument

weak aspen
#

Thanks for the helpful reply.

I guess I am struggling with where the mapping of the type to the stepName gets lost, or whether it even exists in the first place.

export interface FormStep<T extends keyof AllFormData> {
    stepName: T
    onSubmit: (stepName: T, data: AllFormData[T]) => void
}

Isn't this saying that T has to either be animal or food because they are the only two possible keys of AllFormData? If so, then the data can only possibly be either of type Animal when T is animal and Food when the key is food. Doesn't it then follow that I should be able to narrow down the type in the onSubmit function by checking the stepName?

In your chicken example, that would not be a valid T because it's not one of the keys of AllFormData. Is the problem that I have no control over what gets passed at runtime, so someone could pass chicken and the whole thing falls down?

#

Your solution works, although I am not keen on its readability, isn't it more or less doing the same as a type assertion where I check the stepName and then do data as Animal ?

tight cairn
#

FormStep<'animal' | 'food'> is a valid instantiation of that type

#

and this is a valid value of FormStep<'animal' | 'food'>:

{
  stepName: 'animal'
  onSubmit: (stepName: 'food', data: Aninmal) => void
}
weak aspen
#

Right, is there a way of making FormStep only accept one specific value?

tight cairn
#

i think you mean "one specific type", and 'animal' | 'food' is "one type". but semantics aside the answer is no. i'll link to a relevant TS feature request in a moment when i find it

tight cairn
weak aspen
#

Sorry, yes I meant type. I'm pretty new 😬

tight cairn
#

all good. it's hard to talk about "applying generics" without using the term "value"

weak aspen
#

Given there is a feature request open, would you say it's a limitation of TS or am I expecting too much (given my limited understanding)?

tight cairn
#

not sure if this is what you're asking, but it's simply a feature that doesn't currently exist

tight cairn
weak aspen
tight cairn
#

cool. i have to go to an appointment and expect to be gone for a few hours, but we can keep this discussion going when i return

weak aspen
#

As in, I'm approaching/doing it wrong

tight cairn
#

i'm back (for now). one more question: do you need the AllFormData type you had in your original code for other purposes? or was this its only use case?

#

if you need to keep it around anyway it could be used as the source of truth for this parameter union type

#

otherwise it's simpler to write that type directly

#

(i can give an example either way)

weak aspen
#

Yeah, the idea of it is to match up to a completed data entity

#

Or nearly completed

tight cairn
#

"yeah" meaning you do need that type for other purposes?

weak aspen
#

Yes, correct

tight cairn
#

okay, here's one way to use that AllFormData type as the source of truth. i'm generating the possible parameter lists from it (but the concrete form of that type is the same union of tuples that i shared before):

pale latchBOT
#
mkantor#0

Preview:```ts
import {z} from "zod"
import React from "react"
import {useState, useCallback} from "react"

export type AllFormData = {
animal: Animal
food: Food
}

type MergeStepDataParameters = {
[K in keyof AllFormData]: [K, AllFormData[K]]
}[keyof AllFormData]
...```

tight cairn
#

if there's anything that needs further explanation feel free to ask questions. i know the syntax for rest parameter destructuring can be kind of dense