question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Placeholder Type Declarations

See original GitHub issue

Background

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 Foos to some collection, or read Bars, 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.

  1. Enforce that constraints are all identical to each other.
  2. 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 interfaces 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?

"Drop the  - it's cleaner

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:open
  • Created 4 years ago
  • Reactions:66
  • Comments:35 (21 by maintainers)

github_iconTop GitHub Comments

6reactions
weswighamcommented, Jul 2, 2019

For syntax, I’m pretty fine with just

[declare] type Foo;

and

[declare] type Foo extends Whatever;

because it mirrors our other shorthand

declare module "foo";

in which we just take the part of the declaration we do have (the name) and elide the body.

5reactions
dead-claudiacommented, Jun 15, 2019

@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) than exists.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Placeholder type specifiers (since C++11) - cppreference.com
Placeholder type specifiers (since C++11) For variables, specifies that the type of the variable that is being declared will be automatically ...
Read more >
Typescript Placeholder Generic - Stack Overflow
This works fine, but if I now want a component to work with any of these data types, I have to type it...
Read more >
[dcl.type]
Placeholder type deduction is the process by which a type containing a placeholder type is replaced by a deduced type. 2. #. A...
Read more >
Concept-defined placeholder types - HackMD
We have three kinds of placeholder types to define variables – auto , decltype(auto) , and ClassTemplate . The former two deduce arbitrary ......
Read more >
OTL 4.0, Declaration of bind variables
It is sufficient to define scalar data containers to hold just one row. In OTL 4.0, placeholders extended with data type declarations are...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Hashnode Post

No results found