Search code examples
typescripttypescript-genericsnarrowing

Narrowing a variable v of generic type T to ensure the type of v[key] for a specific key


Given a variable v of generic type T extends { a: string }, how can we narrow it so that v['count'] is typed as number AND we can call setProperty(v, 'count', 1) ?

function maybeSetCountToOne<T extends {a: string}>(v: T) {
    if ('count' in v && typeof (v.count) === 'number') {
        v.count = 1 // works (but v.count is unknown)
        setProperty(v, 'count', 1) // fails because 'T["count"]' could be unrelated to 'number'
    }
}

function setProperty<
    T extends {a: string},
    K extends keyof T
>(o: T, k: K, v: T[K]) {
    o[k] = v
    console.log(o.a)
}

All the classic solutions I can think of lead more or less to v typed as T & Record<'count', number>, which doesn't make setProperty(v, 'count', 1) a valid call because T may have a more precise type than number for the count key.

Playground


Solution

  • You can cast the type of the passed number to be (T & {count: number})['count'], essentially narrowing it to a type that's yet to be known, but compatible with T.

    And, since we know, from the comments on the question, that at runtime we don't need to narrow v.count further than number, we use a type guard to assert that v extends {count: number}.

    interface Entity {
        a: string
    }
    
    function countIsNumber<T extends Entity>(x: T): x is T & {count: number} {
        return 'count' in x && typeof x.count === 'number';
    }
    
    function setProperty<T extends Entity & Record<K, unknown>, K extends keyof T>(o: T, k: K, v: T[K]) {
        o[k] = v
    }
    
    function maybeSetCountToOne<T extends Entity>(v: T) {
        type ParamCount = (T & {count: number})['count'];
    
        if (countIsNumber(v)) {
            v.count = 1 // v.count: number - Thanks to the type gaurd
            setProperty(v, 'count', 1 as ParamCount) // No error, thanks to the type cast
        }
    }
    

    Playground

    If we want to avoid type casts. We can use another type guard that asserts that the value we're passing is the same type as v['count']:

    interface Entity {
        a: string
    }
    
    function countIsNumber<T extends Entity>(x: T): x is T & { count: number } {
        return 'count' in x && typeof x.count === 'number';
    }
    
    function countFitsObj<V extends { count: unknown }>(x: V, y: unknown): y is V['count'] {
        return typeof y === typeof x.count;
    }
    
    function setProperty<T extends Entity & Record<K, unknown>, K extends keyof T>(o: T, k: K, v: T[K]) {
        o[k] = v
    }
    
    function maybeSetCountToOne<T extends Entity>(v: T) {
        if (countIsNumber(v)) {
            const newCount = 5;
            if (countFitsObj(v, newCount)) {
                setProperty(v, 'count', newCount) // No error, no typecast
            }
        }
    }
    

    Playground