Placeholder Type Declarations
See original GitHub issueBackground
There are times when users need to express that a type might exist depending on the environment in which code will eventually be run. Typically, the intent is that if such a type can be manufactured, a library can support operations on that type.
One common example of this might be the Buffer
type built into Node.js. A library that operates in both browser and Node.js contexts can state that it handles a Buffer
if given one, but the capabilities of Buffer
aren’t important to the declarations.
export declare function printStuff(str: string): void;
/**
* NOTE: Only works in Node.js
*/
export declare function printStuff(buff: Buffer): void;
One technique to get around this is to “forward declare” Buffer
with an empty interface in the global scope which can later be merged.
declare global {
interface Buffer {}
}
export declare function printStuff(str: string): void;
/**
* NOTE: Only works in Node.js
*/
export declare function printStuff(buff: Buffer): void;
For consuming implementations, a user might need to say that a type not only exists, but also supports some operations. To do so, it can add those members appropriately, and as long as they are identical, they will merge correctly. For example, imagine a library that can specially operate on HTML DOM nodes.
function printStuff(node: HTMLElement) {
console.log(node.innerText);
}
A user might be running in Node.js or might be running with "lib": ["dom"]
, so our implementation can forward-declare HTMLElement
, while also declaring that it contains innerText
.
declare global {
interface HTMLElement {
innerText: string;
}
}
export function printStuff(node: HTMLElement) {
console.log(node.innerText);
}
Issues
Using interface merging works okay, but it has some problems.
Conflicts
Interface merging doesn’t always correctly resolve conflicts between declarations in two interfaces. For example, imagine two declarations of Buffer
that merge, where a function that takes a Buffer
expects it to have a toString
property.
If both versions of toString
are declared as a method, the two appear as overloads which is slightly undesirable.
declare global {
interface Buffer {
// We only need 'toString'
toString(): string;
}
}
export function printStuff(buff: Buffer) {
console.log(buff.toString());
}
////
// in @types/node/index.d.ts
////
interface Buffer {
toString(encoding?: string, start?: number, end?: number): string;
}
Alternatively, if any declaration of toString
is a simple property declaration, then all other declarations will be considered collisions which will cause errors.
declare global {
interface Buffer {
toString(): string
}
}
////
// in @types/node/index.d.ts
////
interface Buffer {
toString: (encoding?: string, start?: number, end?: number) => string;
}
The former is somewhat undesirable, and the latter is unacceptable.
Limited to Object Types
Another problem with the trick of using interfaces for forward declarations is that it only works for classes and interfaces. It doesn’t work for, say, type aliases of union types. It’s important to consider this because it means that the forward-declaration-with-an-interface trick breaks as soon as you need to convert an interface to a union type. For example, we’ve been taking steps recently to convert IteratorResult
to a union type.
Structural Compatibility
An empty interface declaration like
interface Buffer {}
allows assignment from every type except for unknown
, null
, and undefined
, because any other type is assignable to the empty object type ({}
).
Proposal
Proposed is a new construct intended to declare the existence of a type.
exists type Foo;
A placeholder type declaration acts as a placeholder until a type implementation is available. It provides a type name in the current scope, even when the concrete implementation is unknown. When a non-placeholder declaration is available, all references to that type are resolved to an implementation type.
The example given is relatively simple, but placeholder types can also support constraints and type parameters.
// constraints
exists type Foo extends { hello: string };
// type parameters
exists type Foo<T>;
// both!
exists type Foo<T, U> extends { toString(): string };
A formal grammar might appear as follows.
PlaceholderTypeDeclaration ::
exists
[No LineTerminator here]type
BindingIdentifier TypeParametersopt Constraintopt;
Implementation Types
A placeholder type can co-exist with what we might call an implementation type - a type declared using an interface, class, or type alias with the same name as the placeholder type.
In the presence of an implementation type, a placeholder defers to that implementation. In other words, for all uses of a type name that references both a placeholder and an implementation, TypeScript will pretend the placeholder doesn’t exist.
Upper Bound Constraints
A placeholder type is allowed to declare an upper bound, and uses the same syntax as any other type parameter constraint.
exists type Bar extends { hello: string };
This allows implementations to specify the bare-minimum of functionality on a type.
exists type Greeting extends {
hello: string;
}
function greet(msg: Greeting) {
console.log(msg.hello);
}
If a constraint isn’t specified, then the upper bound is implicitly unknown
.
When an implementation type is present, the implementation is checked against its constraint to see whether it is compatible. If not, an implementation should issue an error.
exists type Foo extends {
hello: string
};
// works!
type Foo = {
hello: string;
world: number;
};
exists type Bar extends {
hello: string;
}
// error!
type Bar = {
hello: number; // <- wrong implementation of 'hello'
world: number;
}
Type Parameters
A placeholder type can specify type parameters. These type parameters specify a minimum type argument count for consumers, and a minimum type parameter count for implementation types - and the two may be different!
For example, it is perfectly valid to specify only type arguments which don’t have defaults at use-sites of a placeholder type.
exists type Bar<T, U = number>;
// Acceptable to omit an argument for 'U'.
function foo(x: Bar<string>) {
// ...
}
But an implementation type must declare all type parameters, even default-initialized ones.
exists type Bar<T, U = number>;
// Error!
// The implementation of 'Bar' needs to define a type parameter for 'U',
// and it must also have a default type argument of 'number'.
interface Bar<T> {
// ...
}
Whenever multiple placeholder type or implementation type declarations exist, their type parameter names must be the same.
Different instantiations of placeholders that have type parameters are only related when their type arguments are identical - so for the purposes of variance probing, type parameters are considered invariant unless an implementation is available.
Relating Placeholder Types
Because placeholder types are just type variables that recall their type arguments, relating placeholders appears to fall out from the existing relationship rules.
The intent is
- Two instantiations of the same placeholder type declaration are only related when their type arguments are identical.
- A placeholder type is assignable to any type whose constraint is a subtype of the target.
In effect, two rules in any of our type relationships should cover this:
- S and T are identical types.
- S is a type parameter and the constraint of S is [[related to]] T.
Merging Declarations
Because different parts of an application may need to individually declare that a type exists, multiple placeholder types of the same name can be declared, and much like interface
declarations, they can “merge” in their declarations.
exists type Beetlejuice;
exists type Beetlejuice;
exists type Beetlejuice;
In the event that multiple placeholder types merge, every corresponding type parameter must be identical. On the other hand, placeholder constraints can all differ.
interface Man { man: any }
interface Bear { bear: any }
interface Pig { pig: any }
exists type ManBearPig extends Man;
exists type ManBearPig extends Bear;
exists type ManBearPig extends Pig;
When multiple placeholder types are declared, their constraints are implicitly intersected to a single upper-bound constraint. In our last example, ManBearPig
’s upper bound is effectively Man & Bear & Pig
. In our first example with Beetlejuice
, the upper bound is unknown & unknown & unknown
which is just unknown
.
Prior Art
C and C++ also support forward declarations of types, and is typically used for opaque type handles. The core idea is that you can declare that a type exists, but can never directy hold a value of that type because its shape/size is never known. Instead, you can only deal with pointers to these forward declared types.
struct FileDescriptor;
FileDescriptor* my_open(char* path);
void my_close(FileDescriptor* fd);
This allows APIs to abstract away the shape of forward-declared types entirely, meaning that the size/shape can change. Because these can only be pointers, there isn’t much you can do with them at all (unlike this implementation).
Several other programming languages also support some concept of “opaque” or “existential” types, but are generally not used for the same purposes. Java has wildcards in generics, which is typically used to allow one to say only a bit about how a collection can be used (i.e. you can only write Foo
s to some collection, or read Bar
s, or you can do absolutely nothing with the elements themselves). Swift allows return types to be opaque in the return type by specifying that it is returning some SuperType
(meaning some type variable that extends SuperType
).
FAQ and Rationale
Why can placeholder types have multiple declarations with different constraints?
We have two “obvious” options.
- Enforce that constraints are all identical to each other.
- Allow upper-bound constraints to be additive. This is effectively like intersecting the constraints so that a given type implementation has to satisfy all placeholder declarations.
I believe that additive constraints are the more desirable behavior for a user. The idea is that different parts of your application may need different capabilities, and given that interface
s can already model this with interface merging, using intersections provides a similar mechanism.
Are these just named bounded existential types?
In part, yes! When no implementation type exists, a placeholder type acts as a bounded existential type variable.
Sorry I’m not sure what you’re talking about. Please move along and don’t write blog posts about how TypeScript is adding bounded existential types.
Can placeholder types escape scopes?
function foo() {
exists type Foo;
return null as any as Foo;
}
Maybe! It might be possible to disallow placeholder types from escaping their declaring scope. It might also be reasonable to say that a placeholder can only be declared in the top level of a module or the global scope.
Do we need the exists
keyword?
Maybe we don’t need the exists
keyword - I am open to doing so, but wary that we are unnecessarily abusing the same syntax. I’d prefer to be explicit that this is a new concept with separate syntax, but if we did drop the exists
, we would change the grammar to the following.
PlaceholderTypeDeclaration ::
type
[No LineTerminator here] BindingIdentifier TypeParametersopt Constraintopt;
Issue Analytics
- State:
- Created 4 years ago
- Reactions:66
- Comments:35 (21 by maintainers)
Top GitHub Comments
For syntax, I’m pretty fine with just
and
because it mirrors our other shorthand
in which we just take the part of the declaration we do have (the name) and elide the body.
@fatcerberus I like that idea of a
declare type ...
. Doesn’t create a new keyword, and it arguably is more appropriate to use here (and less of an abuse of terminology) thanexists
.