Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Static types for dynamically named properties #11929

Merged
merged 21 commits into from
Nov 2, 2016
Merged

Static types for dynamically named properties #11929

merged 21 commits into from
Nov 2, 2016

Conversation

ahejlsberg
Copy link
Member

@ahejlsberg ahejlsberg commented Oct 28, 2016

This PR adds new typing constructs that enable static validation of code involving dynamic property names and properties selected by such dynamic names. For example, in JavaScript it is fairly common to have APIs that expect property names as parameters, but so far it hasn't been possible to express the type relationships that occur in those APIs. The PR also improves error messages related to missing properties and/or index signatures.

The PR is inspired by #1295 and #10425 and the discussions in those issues. The PR implements two new type constructs:

  • Index type queries of the form keyof T, where T is some type.
  • Indexed access types of the form T[K], where T is some type and K is a type that is assignable to keyof T (or assignable to number if T contains a numeric index signature).

An index type query keyof T yields the type of permitted property names for T. A keyof T type is considered a subtype of string. When T is not a type parameter, keyof T is resolved as follows:

  • If T has no apparent string index signature, keyof T is a type of the form "p1" | "p2" | ... | "pX", where the string literals represent the names of the public properties of T. If T has no public properties, keyof T is the type never.
  • If T has an apparent string index signature, keyof T is the type string.

Note that keyof T ignores numeric index signatures. To properly account for those we would need a numericstring type to represent strings contain numeric representations.

An indexed access type T[K] requires K to be a type that is assignable to keyof T (or assignable to number if T contains a numeric index signature) and yields the type of the property or properties in T selected by K. T[K] permits K to be a type parameter, in which case K must be constrained to a type that is assignable to keyof T. Otherwise, when K is not a type parameter, T[K] is resolved as follows:

  • If K is a union type K1 | K2 | ... | Kn, T[K] is equivalent to T[K1] | T[K2] | ... | T[Kn].
  • If K is a string literal type, numeric literal type, or enum literal type, and T contains a public property with the name given by that literal type, T[K] is the type of that property.
  • If K is a type assignable to number and T contains a numeric index signature, T[K] is the type of that numeric index signature.
  • If K is a type assignable to string and T contains a string index signature, T[K] is the type of that string index signature.

Some examples:

interface Thing {
    name: string;
    width: number;
    height: number;
    inStock: boolean;
}

type K1 = keyof Thing;  // "name" | "width" | "height" | "inStock"
type K2 = keyof Thing[];  // "length" | "push" | "pop" | "concat" | ...
type K3 = keyof { [x: string]: Thing };  // string

type P1 = Thing["name"];  // string
type P2 = Thing["width" | "height"];  // number
type P3 = Thing["name" | "inStock"];  // string | boolean
type P4 = string["charAt"];  // (pos: number) => string
type P5 = string[]["push"];  // (...items: string[]) => number
type P6 = string[][0];  // string

An indexed access type T[K] permits K to be a type parameter that is constrained to keyof T. This makes it possible to do parametric abstraction over property access:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
    return obj[key];  // Inferred type is T[K]
}

function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]) {
    obj[key] = value;
}

function f1(thing: Thing, propName: "name" | "width") {
    let name = getProperty(thing, "name");  // Ok, type string
    let size = getProperty(thing, "size");  // Error, no property named "size"
    setProperty(thing, "width", 42);  // Ok
    setProperty(thing, "color", "blue");  // Error, no property named "color"
    let nameOrWidth = getProperty(thing, propName);  // Ok, type string | number
}

function f2(tuple: [string, number, Thing]) {
    let length = getProperty(tuple, "length");  // Ok, type number
    const TWO = "2";
    let t0 = getProperty(tuple, "0");  // Ok, type string
    let t1 = getProperty(tuple, "1");  // Ok, type number
    let t2 = getProperty(tuple, TWO);  // Ok, type Thing
}

class Component<PropType> {
    props: PropType;
    getProperty<K extends keyof PropType>(key: K) {
        return this.props[key];
    }
    setProperty<K extends keyof PropType>(key: K, value: PropType[K]) {
        this.props[key] = value;
    }
}

function f3(component: Component<Thing>) {
    let width = component.getProperty("width");  // Ok, type number
    component.setProperty("name", "test");  // Ok
}

function pluck<T, K extends keyof T>(array: T[], key: K) {
    return array.map(x => x[key]);
}

function f4(things: Thing[]) {
    let names = pluck(things, "name");  // string[]
    let widths = pluck(things, "width");  // number[]
}

Note: This description has been edited to reflect the changes implemented in #12425.

@weswigham
Copy link
Member

weswigham commented Oct 29, 2016

@ahejlsberg Does this also adjust the logic for handling actual index expressions? For example,

interface Foo {
  a: 0
  b: 1
}
var x: "a" | "b";
var y: Foo;
var z = y[x]; // Is this Foo["a" | "b"] or 0 | 1 (rather than relying on a nonexistant index signature for the type or being any)?

Or is actual indexing unchanged? IMO, it would make the most sense if the typespace indexing operator and the valuespace one affected type flow in the same way.

This was the last half-feature/concern I had identified in the original issue.

@ahejlsberg
Copy link
Member Author

ahejlsberg commented Oct 29, 2016

@weswigham The operation y[x] will produce type 0 | 1 once I do the remaining work to unify indexing in the expression world with indexing in the type world.

EDIT: The remaining unification is done and y[x] now produces type 0 | 1.

BTW, there is an interesting issue around reading vs. writing when the index type is not a unit type. Technically, it is only safe to union together the resulting values when you're reading. For writing, the only safe construct would be an intersection, but that is rarely useful. So, we're planning to make writing an error when the index type is not a unit type:

interface Foo { a: 0, b: 1 }

function f1(foo: Foo, x: "a" | "b") {
    const c = foo[x];  // Ok, type 0 | 1
    foo[x] = c;  // Error, absent index signatures an index expresion must be of a unit type
}

However, in the parametric case we make no distinction between keyof T types used for reading vs. writing, so you can "cheat":

function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]) {
    obj[key] = value;
}

function f2(foo: Foo, x: "a" | "b") {
    const c = foo[x];  // Ok, type 0 | 1
    setProperty(foo, x, c);  // Ok
}

It actually seems fairly reasonable to allow this. Really, there are three distinct possibilities:

  • The operation is known to never be safe. We always error in such cases.
  • The operation could be safe provided index and value match appropriately. We permit that only in generic cases.
  • The operation is known to always be safe. We never error in such cases.

The alternative is to have distinct safe for reading, safe for writing, and safe for reading and writing versions of keyof T. That seems like overkill, or at best an orthogonal issue we should consider if we ever decide to go all in on variance annotations.

@ahejlsberg
Copy link
Member Author

ahejlsberg commented Oct 31, 2016

Latest set of commits unify type checking of indexed access expressions and indexed access types. Also, improved error messages for a number of errors related to property/element access and constant/read-only checking.

# Conflicts:
#	src/compiler/diagnosticMessages.json
@@ -2860,9 +2873,22 @@ namespace ts {
/* @internal */
resolvedApparentType: Type;
/* @internal */
resolvedIndexType: IndexType;
/* @internal */
resolvedIndexedAccessTypes: IndexedAccessType[];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why array instead of map? the array is going to be sparse if indexed with type id.

Copy link
Member Author

@ahejlsberg ahejlsberg Nov 1, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it will be sparse, in the same way that our nodeLinks and symbolLinks arrays are sparse. I believe this performs better than a map.

return indexedAccessTypes[objectType.id] || (indexedAccessTypes[objectType.id] = createIndexedAccessType(objectType, indexType));
}

function getPropertyTypeForIndexType(objectType: Type, indexType: Type, accessNode?: ElementAccessExpression | IndexedAccessTypeNode) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should not this be getPropertyTypeForIndexedType or getPropertyTypeForIndexAccess since it operates on the indexed type rather than the index type.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, it operates both on the indexed type and the index type. So, technically it should be getPropertyTypeForIndexedTypeIndexedByIndexType. 😃 Honestly, not sure what would be a good name.

@@ -2909,7 +2921,7 @@
"category": "Error",
"code": 7016
},
"Index signature of object type implicitly has an 'any' type.": {
"Element implicitly has an 'any' type because type '{0}' has no index signature.": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 this is much better now!

error(indexNode, Diagnostics.Property_0_does_not_exist_on_type_1, (<LiteralType>indexType).text, typeToString(objectType));
}
else if (indexType.flags & (TypeFlags.String | TypeFlags.Number)) {
error(accessNode, Diagnostics.Type_0_has_no_matching_index_signature_for_type_1, typeToString(objectType), typeToString(indexType));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i do not see any tests for this scenario.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I still haven't added tests.

Copy link
Contributor

@mhegazy mhegazy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not see any tests for keyof and/or T[K]. also please include some declaration emit tests.

@HerringtonDarkholme
Copy link
Contributor

I wonder whether index access type works with constrained generic parameter. For example, only the last alias of the following three is resolved to number.

type A<T extends {[K in "test"]: any}> = T["test"]
type B<T extends {test: any}> = T["test"]
type C<T extends {test: any}, K extends keyof T> = T[K]

type A1 = A<{test: number}>
type B1 = B<{test: number}>
type C1 = C<{test: number}, "test">

Is this intended?

@ahejlsberg
Copy link
Member Author

@HerringtonDarkholme In an indexed access T["test"], where T is a type parameter, we eagerly resolve the type based on the constraint of T. So if T has the constraint { test: any }, we'll eagerly resolve to type any. We only defer the operation when the index type is also a type parameter. Deferring T["test"] would have a lot of downstream effects on existing code because it changes every access to a property of this within a class to be a deferred generic type. It might be possible to do it at some point, but it is not a short-term option.

@fula2013
Copy link

@ahejlsberg what about support for regex check type? like this:
type SomeTel = match of /^\d{4}-\d{6}$/; var tel: SomeTel = '0768-888888'; //is ok

@CodySchaaf
Copy link

Is there no way to make private members accessible? Would be nice if you could do something similar to the readonly transformation.

My Specific example is when transforming the current class to the IOnChangesObject in angular.

type IOnChangesObject<T> = {[P in keyof T]: ng.IChangesObject<T[P]>}

$onChanges(changesObj: IOnChangesObject<Ctrl>) { 
//both private and public attributes should work on changesObj
}

@nahakyuu
Copy link

type Foo<T> = T & {
    Test: () => void;
}
type Foo1<T> = keyof Foo<T>;

type Foo2 = Foo1<{ prop: string }>;

type Foo2 = "Test"

@nevir
Copy link

nevir commented Dec 20, 2016

Should this also work for object literals? I would have expected the following to also work:

const thing = {
  a: 'foo',
  b: 'bar',
};

// Expected: "a" | "b"
// Actual: Cannot find name 'thing'
type thingKey = keyof thing;

@gcnew
Copy link
Contributor

gcnew commented Dec 20, 2016

@nevir Use typeof to get the type of thing

type thingKey = keyof typeof thing;

@nevir
Copy link

nevir commented Dec 20, 2016

Ah! Thanks!

@Artazor
Copy link
Contributor

Artazor commented Mar 14, 2017

just want to share it here:
(Advanced usage of mapped types and static types for dynamically named properties)

//////////////////////////////////  USAGE /////////////////////////////////

var sequelize = new Sequelize();
var Users = sequelize.define("users", {
    firstName: STRING,
    lastName: CHAR(123),
    age: INTEGER,
    visits: { type: INTEGER, length: 4}
});

var u = Users.create({age: 22}); // only valid fields/types allowed 
u.firstName = "John"; // types are checked at compile time
u.lastName = "Doe"
u.visits = 123;


///////////////////////////////// DECLARATIONS /////////////////////////////


interface ABSTRACT_STATIC<T> {
    prototype: ABSTRACT<string, T>;
}
interface ABSTRACT_BASE<T> {
    stringify(value: T, options): string;
    validate(value: T);
}

interface ABSTRACT<Key, T> extends ABSTRACT_BASE<T> { 
    key: Key;
    dialectTypes: string;
    toSql: string;
    warn(link: string, text);
}

interface STRING_OPTIONS { length?: number, binary?: boolean }
interface STRING<Key> extends ABSTRACT<Key, string> {
    readonly BINARY: this
}
interface STRING_STATIC<Key, T extends STRING<Key>> extends ABSTRACT_STATIC<string> {
    new(length?: number, binary?: boolean): T;
    new(options: STRING_OPTIONS): T;
    (length?: number, binary?: boolean): T;
    (options: STRING_OPTIONS): T;
}
declare const STRING: STRING_STATIC<"STRING", STRING<"STRING">>;

interface CHAR extends STRING<"CHAR"> {}
interface CHAR_STATIC extends STRING_STATIC<"CHAR", CHAR> {}
declare const CHAR: CHAR_STATIC;

interface NUMBER_OPTIONS {
    length?: number,
    zerofill?: boolean,
    decimals?: number,
    precision?: number,
    scale?: number,
    unsigned?: boolean
}
interface NUMBER<Key> extends ABSTRACT<Key, number> {
    readonly UNSIGNED: this
    readonly ZEROFILL: this
}
interface NUMBER_STATIC<Key, T extends NUMBER<Key>> extends ABSTRACT_STATIC<number> {
    new(options: NUMBER_OPTIONS): T;
    (options: NUMBER_OPTIONS): T;
}
declare const NUMBER: NUMBER_STATIC<"NUMBER", NUMBER<"NUMBER">>;

interface INTEGER extends NUMBER<"INTEGER"> {}
interface INTEGER_STATIC extends NUMBER_STATIC<"INTEGER", INTEGER> {
    new(): INTEGER;
    (): INTEGER;
    new(length: number): INTEGER;
    (length: number): INTEGER;
}
declare const INTEGER: INTEGER_STATIC;
interface OPTIONS<T> { type: ABSTRACT_STATIC<T> }

interface STRING_OPTIONS_TYPE extends OPTIONS<string>, STRING_OPTIONS { }
interface NUMBER_OPTIONS_TYPE extends OPTIONS<number>, NUMBER_OPTIONS { }

type FIELD<T> = ABSTRACT_STATIC<T>
    | OPTIONS<T> & (
        STRING_OPTIONS_TYPE |
        NUMBER_OPTIONS_TYPE
        )
    | ABSTRACT_BASE<T> 

type INIT<T> = {[P in keyof T]?:T[P]}

declare class Factory<T> {
    create(initial?: INIT<T>): T
}

declare class Sequelize {
    define<T>(tableName: string, fields: {[P in keyof T]: FIELD<T[P]>}):Factory<T> 
}

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.