I. Index Types (Index types)
Index types let static checking cover "dynamic" scenarios where types are uncertain (cannot be enumerated), for example:
function pluck(o, names) {
return names.map(n => o[n]);
}
pluck function can extract the part of properties specified by names from o, exists 2 type constraints:
-
Parameter
namescan only contain properties thatohas -
Return type depends on the type of property values on parameter
o
These two constraints can both be described through generics:
interface pluck {
<T, K extends keyof T>(o: T, names: K[]): T[K][]
}
let obj = { a: 1, b: '2', c: false };
// Parameter check
// Error Type 'string' is not assignable to type '"a" | "b" | "c"'.
pluck(obj, ['n']);
// Return type inference
let xs: (string | number)[] = pluck(obj, ['a', 'b']);
P.S. interface can describe function types, specifically see II. Functions
Appeared 2 new things:
-
keyof: index type query operator -
T[K]: indexed access operator:
Index Type Query Operator
keyof T takes all public property names on type T to form union type, for example:
// Equivalent to let t: { a: number; b: string; c: boolean; }
let t: typeof obj;
// Equivalent to let availableKeys: "a" | "b" | "c"
let availableKeys: keyof typeof obj;
declare class Person {
private married: boolean;
public name: string;
public age: number;
}
// Equivalent to let publicKeys: "name" | "age"
let publicKeys: keyof Person;
P.S. Note, different from typeof facing values, keyof is针对 types, not values (therefore keyof obj is illegal)
This type query capability is very meaningful in scenarios like pluck where property names cannot be known in advance (or cannot be enumerated)
Indexed Access Operator
Similar to keyof, another type query capability is accessing types by index (T[K]), equivalent to property access operator at type level:
function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
return o[name]; // o[name] is of type T[K]
}
let c: boolean = getProperty(obj, 'c');
// Equivalent to
let cValue: typeof obj['c'] = obj['c'];
That is to say, if t: T, k: K, then t[k]: T[K]:
type typesof<T, K extends keyof T> = T[K];
let a: typesof<typeof obj, 'a'> = obj['a'];
let bOrC: typesof<typeof obj, 'b' | 'c'> = obj['b'];
bOrC = obj['c'];
// Error Type 'number' is not assignable to type 'string | boolean'.
bOrC = obj['a'];
Index Types and String Index Signatures
keyof and T[K] also apply to string index signatures, for example:
interface NetCache {
[propName: string]: object;
}
// string | number type
let keyType: keyof NetCache;
// object type
let cached: typesof<NetCache, 'http://example.com'>;
Notice keyType's type is string | number, not expected string, this is because numeric indices in JavaScript will be converted to string indices, for example:
let netCache: NetCache;
netCache[20190101] === netCache['20190101']
That is to say, key's type can be string or number, i.e. string | number. If really want to exclude number, can complete through built-in Extract type alias:
/**
* Extract from T those types that are assignable to U
*/
type Extract<T, U> = T extends U ? T : never;
(Extracted from TypeScript/lib/lib.es5.d.ts)
let stringKey: Extract<keyof NetCache, string> = 'http://example.com';
Of course, generally no need to do this, because from type perspective, key: string | number is reasonable
P.S. For more related discussions, see Keyof inferring string | number when key is only a string
II. Mapped Types
Similar to index types, another way to derive new types from existing types is mapping:
In a mapped type, the new type transforms each property in the old type in the same way.
For example:
type Stringify<T> = {
[P in keyof T]: string
}
// toString() all property values
function toString<T>(obj: T): Stringify<T> {
return Object.keys(obj)
.reduce((a, k) =>
({ ...a, [k]: obj[k].toString() }),
Object.create(null)
);
}
let stringified = toString({ a: 1, b: 2 });
// Error Type 'number' is not assignable to type 'string'.
stringified = { a: 1 };
Stringify implements type mapping from { [propName: string]: any } to { [propName: string]: string }, but doesn't look very useful. Actually, more common usage is to change key's properties through mapped types, such as making all properties of a type optional or readonly:
type Partial<T> = {
[P in keyof T]?: T[P];
}
type Readonly<T> = {
readonly [P in keyof T]: T[P];
}
(Extracted from TypeScript/lib/lib.es5.d.ts)
let obj = { a: 1, b: '2' };
let constObj: Readonly<typeof obj>;
let optionalObj: Partial<typeof obj>;
// Error Cannot assign to 'a' because it is a read-only property.
constObj.a = 2;
// Error Type '{}' is missing the following properties from type '{ a: number; b: string; }': a, b
obj = {};
// Correct
optionalObj = {};
Syntax Format
Most intuitive example:
// Find a "type set"
type Keys = 'a' | 'b';
// Get new type { a: boolean, b: boolean } through type mapping
type Flags = { [K in Keys]: boolean };
[K in Keys] form is similar to index signatures, just fused with for...in syntax. Among them:
-
K: type variable, binds to each property in turn, corresponds to each property name's type -
Keys: [union type](/articles/组合类型与类型保护-typescript 笔记 9/#articleHeader3) composed of string literals, represents a set of property names (types) -
boolean: mapping result type, i.e. each property value's type
Similarly, [P in keyof T] just finds keyof T as (property name) type set, thereby mapping existing types to get new types
P.S. Additionally, Partial and Readonly can both completely preserve source type information (take property names and value types from input source type, only exist differences in modifiers, source types and new types have compatibility relationship), called homomorphic transformation, while Stringify discards source property value types, belongs to non-homomorphic transformation
"Unwrapping" Inference
Mapping types is equivalent to "boxing" at type level:
// Wrapper type
type Proxy<T> = {
get(): T;
set(value: T): void;
}
// Boxing (ordinary type to wrapper type mapping)
type Proxify<T> = {
[P in keyof T]: Proxy<T[P]>;
}
// Boxing function
function proxify<T>(o: T): Proxify<T> {
let result: Proxify<T>;
// ... wrap proxies ...
return result;
}
For example:
// Ordinary type
interface Person {
name: string,
age: number
}
let lily: Person;
// Boxing
let proxyProps: Proxify<Person> = proxify(lily);
Similarly, can also "unbox":
function unproxify<T>(t: Proxify<T>): T {
let result = {} as T;
for (const k in t) {
result[k] = t[k].get();
}
return result;
}
let originalProps: Person = unproxify(proxyProps);
Can automatically infer the last line's unproxify function type as:
function unproxify<Person>(t: Proxify<Person>): Person
Took out Person from parameter type proxyProps: Proxify<Person> as return value type, so-called "unboxing"
III. Conditional Types
Conditional types are used to express non-uniform type mapping, can select one from two types based on type compatibility relationship (i.e. condition):
T extends U ? X : Y
Semantics similar to ternary operator, if T is subtype of U, then is X type, otherwise is Y type. Additionally, there's another situation where condition's truth cannot be determined (cannot determine whether T is subtype of U), at this time is X | Y type, for example:
declare function f<T extends boolean>(x: T): T extends true ? string : number;
// x's type is string | number
let x = f(Math.random() < 0.5)
Additionally, if T or U contains type variables, must wait until type variables all have corresponding concrete types before can get conditional type result:
When T or U contains type variables, whether to resolve to X or Y, or to defer, is determined by whether or not a the type system has enough information to conclude that T is always assignable to U.
For example:
interface Foo {
propA: boolean;
propB: boolean;
}
declare function f<T>(x: T): T extends Foo ? string : number;
function foo<U>(x: U) {
// a's type is U extends Foo ? string : number
let a = f(x);
let b: string | number = a;
}
Among them a's type is U extends Foo ? string : number (i.e. condition uncertain situation), because in f(x) x's type U is not yet determined, cannot know whether U is subtype of Foo. But conditional types only have two possible types, so let b: string | number = a; is definitely legal (no matter what type x is)
Distributive Conditional Types
In distributive conditional types, the checked type is a naked type parameter. Its special characteristic is satisfying distributive law:
(A | B | C) extends U ? X : Y
Equivalent to
(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)
For example:
// Nested conditional types similar to pattern matching
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" : "object";
// T type equivalent to union type string" | "function
type T = TypeName<string | (() => void)>;
Additionally, in T extends U ? X : Y, T appearing in X all have U type constraint:
type BoxedValue<T> = { value: T };
type BoxedArray<T> = { array: T[] };
type Boxed<T> = T extends any[] ? BoxedArray<T[number]> : BoxedValue<T>;
// T type equivalent to union type BoxedValue<string> | BoxedArray<boolean>
type T = Boxed<string | boolean[]>;
In above example Boxed<T>'s True branch has any[] type constraint, therefore can access through index (T[number]) to get array element type
Application Scenarios
Conditional types combined with mapped types can achieve targeted type mapping (different source types can correspond to different mapping rules), for example:
type FunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T];
// Extract all function type properties
type FunctionProperties<T> = Pick<T, FunctionPropertyNames<T>>;
interface Part {
id: number;
name: string;
subparts: Part[];
updatePart(newName: string): void;
}
// T type equivalent to string literal type "updatePart"
type T = FunctionPropertyNames<Part>;
And distributive conditional types are usually used to filter union types:
type Diff<T, U> = T extends U ? never : T;
// T type equivalent to union type "b" | "d"
type T = Diff<"a" | "b" | "c" | "d", "a" | "c" | "f">;
// Further
type NeverNullable<T> = Diff<T, null | undefined>;
function f1<T>(x: T, y: NeverNullable<T>) {
x = y;
// Error Type 'T' is not assignable to type 'Diff<T, null>'.
y = x;
}
Type Inference in Conditional Types
In conditional types' extends clause, can introduce a type variable to be inferred through infer declaration, for example:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
In above example introduced type variable R to represent function return type, and referenced in True branch, thereby extracting return type
P.S. Specially, if exists overloads, take last signature (according to convention, last is usually most general) for inference, for example:
declare function foo(x: string): number;
declare function foo(x: number): string;
declare function foo(x: string | number): string | number;
// T type equivalent to union type string | number
type T = ReturnType<typeof foo>;
P.S. For more examples see Type inference in conditional types
Predefined Conditional Types
TypeScript also has some built-in commonly used conditional types:
// Remove parts belonging to U's subtypes from T, i.e. Diff in previous example
type Exclude<T, U> = T extends U ? never : T;
// Filter out parts belonging to U's subtypes from T, Filter in previous example
type Extract<T, U> = T extends U ? T : never;
// Remove null and undefined parts from T
type NonNullable<T> = T extends null | undefined ? never : T;
// Extract function type's return type
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
// Extract constructor type's instance type
type InstanceType<T extends new (...args: any) => any> = T extends new (...args: any) => infer R ? R : any;
(Extracted from TypeScript/lib/lib.es5.d.ts)
For specific examples see Predefined conditional types
IV. Summary
Besides [type composition](/articles/组合类型与类型保护-typescript 笔记 9/#articleHeader1), another 2 ways to produce new types are type queries and type mapping
Type queries:
- Index types: take part of existing types to produce new types
Type mapping:
- Mapped types: map existing types to get new types
- Conditional types: allow simple ternary operations based on type compatibility relationships as conditions, used to express non-uniform type mapping
No comments yet. Be the first to share your thoughts.