镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Proposal - Nested property access notation for types #10693

Description

This strawman proposal aims to bring type-safety to a common "nested properties access" pattern in JavaScript libraries.

A common eww-case

Consider the following snippets that use RxJS & Immutable.js: they select / update nested properties using sequences of literal property names, and are currently impossible to model in a type-safe way (making their use perillous / brittle at best):

const obs = Observable.of({a: {b: {c: 1}}});
obs.pluck('a', 'b', 'c') // Observable<number>

var nested1 = Immutable.fromJS({a: {b: {c: 1}}});
nested1.getIn(['a', 'b', 'c']) // number
var nested2 = nested1.updateIn(['a', 'b', 'd'], value => value + 1);

To sleep better at night, I'd like to be able to declare something like:

interface Observable<T> {
  pluck<Props extends const string[]>(...names: Props): T[Props];
}

// Pseudo-code, may need more work:
interface Nested<T> {
  updateIn<Props extends const string[]>(
      keyPath: Props, updater: (value: T[Props]) => T[Props]): Immutable.List;
}
function fromNestedJs<T>(t: T): Immutable.Map & Nested<T> {
  return Immutable.fromJs(t) as any;
}

(see alternative syntaxes at the bottom)

Literal index types & type property access syntax

To achieve that, we could introduce the following notations / concepts in TypeScript (A <: B below means any value of type A can be assigned to variables of type B):

  • Literal index types const string and const number. These types sit between their respective literal types and primitive types:
    • 'foo' <: const string <: string (any string literal type is a literal index type)
    • 1 <: const number <: number (any number literal type is a literal index type)
  • Literal index array types (matching A <: (const string | const number)[]) are the types of arrays literals containing any mix of string literals and number literals. They obey the same rules as the other literal types:
    • [1, 'a'] is an literal index array type
    • [1, string] is not a literal index array type
    • [const string] is not a literal index array type
  • A property access notation for types.
    • Given a literal index type I (I <: (const string | const number)):

      T[I] is typeof t[i] where t: T and i: I

      • If I is a literal string type and T has a property which name (or constant computed property key) is i, then T[I] will have that property's type.
      • If T has an index operator that accepts keys of type I, then T[I] will be the return type of that operator. Properties are resolved before index operators ("property wins over index", TBC).
      • If T is any, T[I] is any and a warning / error is emitted if --noImplicitAny is set
      • Otherwise, an No property ${i} found on type ${T} error is emitted.
    • For a literal index array type A of length n (A <: (const string | const number)[]):

      T[A] yields typeof t[a[0]][a[1]]...[a[n - 1]] where t: T and a: A

      i.e. T[[P0, P1... Pn]] = ((T[P0])[P1])...[Pn] (applying rules for property access with a single literal index type above, one step at a time)

Where would it be useful?

To declare type signatures of lens-like APIs:

Where would it be useless?

To implement those APIs: type checks will have to go through any at some point.

This is tailored for declarations only.

More examples

// Mix number and string literals:
const values: {a: number}[] = [{a: 1}, {a: 2}];
select(values, 1, 'a') // number

// Abuse tuples and mix with arrays:
const tuple: [number, [string[], [boolean]]] = [1, ['2', [true]]];
select(tuple, 1, 1, 0) // boolean
select(tuple, 1, 0, 10000) // string

Possible Extensions

There is no concept of literal symbol yet, but symbol could clearly make it in this proposal in the future:

const X = Symbol.for('X');
const value: {[X]: number} = {[X]: 1};
select(value, X); // number

Syntax concerns

function pluck<Props extends const string[]>(...names: Props): T[Props];

Potential issues:

  • Property access notation for types could become ambiguous if TypeScript ever adopts C-style fixed-size array types (e.g. number[8] for array of size 8), although tuples already fulfill many use-cases of fixed-size arrays.
  • const + types brings lots of memories from C++ development (where const types define some sticky / recursive immutability). Only having number and string, which are immutable types, mitigates that risk (even if ES2100 introduces const type modifiers).

Alternatives considered:

  • T[Props...]: my favourite alternative (distinct-enough from spread-syntax, yet reuses existing ... token)
  • T[...Props]: nope (too close to spread syntax and would conflict with the awesome variadic types proposal)

Other ways to address that use-case?

I'm new to TypeScript and may have overlooked some nicer typeof-way to do part or all of these things: please enlighten me and thanks for reading!

Activity

  1. tinganho commented on Sep 3, 2016

    @tinganho
    Contributor

    Just my opinions:

    (1) Since #1295 got accepted. I think at least this proposal should have the parameter name between the brackets as in T[names], and not the type as in T[Props].

    (2) I think the syntax T[names] is too close to T[prop] in #1295. There is nothing in the syntax that has a meaning of "drilling down" the props of T. The syntax looks like there is only a one-level property access instead of variadic level access.

    (3) 👍 Would rather see T[props...] than T[props].

  2. aluanhaddad commented on Sep 3, 2016

    @aluanhaddad
    Contributor

    Tingan Ho (@tinganho) Regarding the meaning of T[names] not indicating drilling into an object, I think the usage patterns themselves suffer from the same problems.

    Without documentation, I have no idea what the following code is doing

    const obs = Observable.of({a: {b: {c: 1}}});
    obs.pluck('a', 'b', 'c') // Observable<number>
    
    var nested1 = Immutable.fromJS({a: {b: {c: 1}}});
    nested1.getIn(['a', 'b', 'c']) // number
    var nested2 = nested1.updateIn(['a', 'b', 'd'], value => value + 1);

    Based purely on syntactic intuition and function naming, I would expect the first to yield an object with the properties a, b, and c.

    Obviously these libraries already work the way they do...

  3. mhegazy commented on Apr 27, 2017

    @mhegazy
    Contributor

    This should be addressed by #11929

  4. shelakel commented on May 3, 2017

    @shelakel

    EDIT:
    It turns out TypeScript 2.3.2 is unable to infer the keys of deeply nested properties. E.g.

    type T1<T> = [(keyof T)];
    type T2<T> = [(keyof T), (keyof T[(keyof T)])];
    type T3<T> = [(keyof T), (keyof T[(keyof T)]), (keyof T[(keyof T)][keyof T[(keyof T)]])];
    
    type TP<T> = T1<T> | T2<T> | T3<T>;
    
    interface IX {
        x: {
            x1: void;
        }
        y: {
            y1: void;
        }
    }
    
    function select<M>(path: TP<M>): void {
        //
    }
    
    select<IX>(["y", "y1"]);
    Argument of type '["y", string]' is not assignable to parameter of type 'TP<IX>'.
      Type '["y", string]' is not assignable to type '["x" | "y", never]'.
        Types of property '1' are incompatible.
          Type 'string' is not assignable to type 'never'.
    

    Mohamed Hegazy (@mhegazy) - #11929 didn't solve this issue - can you reopen?

  5. mhegazy commented on May 6, 2017

    @mhegazy
    Contributor

    Please file a new issue instead.

  6. Kyry11 commented on Jan 7, 2018

    @Kyry11

    Hey Schalk (@shelakel), reviving this old thread 🙂

    I ran into this issue too when trying to build a deep type safe property value extractor and ended up with the following utility method:

    interface TypedExtractor {
      <T, K1 extends keyof T>(object: T, key1: K1): T[K1];
      <T, K1 extends keyof T, K2 extends keyof T[K1]>(object: T, key1: K1, key2: K2): T[K1][K2];
      <T, K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2]>(object: T, key1: K1, key2: K2, key3: K3): T[K1][K2][K3];
      <T, K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2], K4 extends keyof T[K1][K2][K3]>(object: T, key1: K1, key2: K2, key3: K3, key4: K4): T[K1][K2][K3][K4];
      <T, K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2], K4 extends keyof T[K1][K2][K3], K5 extends keyof T[K1][K2][K3][K4]>(object: T, key1: K1, key2: K2, key3: K3, key4: K4, key5: K5): T[K1][K2][K3][K4][K5];
      <T, K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2], K4 extends keyof T[K1][K2][K3], K5 extends keyof T[K1][K2][K3][K4], K6 extends keyof T[K1][K2][K3][K4][K5]>(object: T, key1: K1, key2: K2, key3: K3, key4: K4, key5: K5, key6: K6): T[K1][K2][K3][K4][K5][K6];
      <T, K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2], K4 extends keyof T[K1][K2][K3], K5 extends keyof T[K1][K2][K3][K4], K6 extends keyof T[K1][K2][K3][K4][K5], K7 extends keyof T[K1][K2][K3][K4][K5][K6]>(object: T, key1: K1, key2: K2, key3: K3, key4: K4, key5: K5, key6: K6, key7: K7): T[K1][K2][K3][K4][K5][K6][K7];
      <T, K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2], K4 extends keyof T[K1][K2][K3], K5 extends keyof T[K1][K2][K3][K4], K6 extends keyof T[K1][K2][K3][K4][K5], K7 extends keyof T[K1][K2][K3][K4][K5][K6], K8 extends keyof T[K1][K2][K3][K4][K5][K6][K7]>(object: T, key1: K1, key2: K2, key3: K3, key4: K4, key5: K5, key6: K6, key7: K7, key8: K8): T[K1][K2][K3][K4][K5][K6][K7][K8];
      <T, K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2], K4 extends keyof T[K1][K2][K3], K5 extends keyof T[K1][K2][K3][K4], K6 extends keyof T[K1][K2][K3][K4][K5], K7 extends keyof T[K1][K2][K3][K4][K5][K6], K8 extends keyof T[K1][K2][K3][K4][K5][K6][K7], K9 extends keyof T[K1][K2][K3][K4][K5][K6][K7][K8]>(object: T, key1: K1, key2: K2, key3: K3, key4: K4, key5: K5, key6: K6, key7: K7, key8: K8, key9: K9): T[K1][K2][K3][K4][K5][K6][K7][K8][K9];
      <T, K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2], K4 extends keyof T[K1][K2][K3], K5 extends keyof T[K1][K2][K3][K4], K6 extends keyof T[K1][K2][K3][K4][K5], K7 extends keyof T[K1][K2][K3][K4][K5][K6], K8 extends keyof T[K1][K2][K3][K4][K5][K6][K7], K9 extends keyof T[K1][K2][K3][K4][K5][K6][K7][K8], K10 extends keyof T[K1][K2][K3][K4][K5][K6][K7][K8][K9]>(object: T, key1: K1, key2: K2, key3: K3, key4: K4, key5: K5, key6: K6, key7: K7, key8: K8, key9: K9, key10: K10): T[K1][K2][K3][K4][K5][K6][K7][K8][K9][K10];
    }
    
    export const X: TypedExtractor = (object: any, ...keys: string[]) =>
                                      object && keys.every(key => ((object = object[key]) !== undefined)) ? object : undefined;

    Due to the TypeScript's inability to express the above constraint implicitly, I ended up writing explicit signatures for 1-10 levels of depth in the object hierarchy. It will provide type safe property access and infer return type.

    Maybe it will be useful to you until (or if) TypeScript supports this type of constraint implicitly or introduces support for Elvis operator.

    Cheers,
    Kyryll

  7. locked and limited conversation to collaborators on Jul 3, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    FixedA PR has been merged for this issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions