bastianplsfix

Interfaces and type aliases

There are two ways to name an object type, and they are near enough interchangeable that the choice is usually style. The difference that is not style: an interface is open and an alias is closed. An interface merges with another interface of the same name, can be extended, and produces one merged type when it is. An alias is a name for exactly what follows the =, its counterpart to extends is &, and & is a different operation in a way that only shows up when the two types disagree.

Most of what you write ends up an alias anyway, because only an alias can name a union, a tuple, a primitive, or a mapped type. The object types page is the entry for what goes inside either one.

Create programs/interfaces-and-type-aliases.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import { assert, assertEquals, assertStrictEquals } from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

one value, two names

Deno.test("one value, two names", () => {
type ProductAlias = {
sku: string;
price(quantity: number): number;
};

interface ProductInterface {
sku: string;
price(quantity: number): number;
}

const carrot: ProductAlias = { sku: "veg-1", price: (n) => n * 40 };
const asInterface: ProductInterface = carrot;
const backAgain: ProductAlias = asInterface;

assertStrictEquals(backAgain.price(2), 80);
});
Check programs/interfaces-and-type-aliases.test.ts
running 1 test from ./programs/interfaces-and-type-aliases.test.ts
one value, two names ... ok (181µs)

ok | 1 passed | 0 failed (1ms)

One value, moved between the two types in both directions, with no complaint. Object types match by structure, so a value that fits one fits the other, and the name on the front of the declaration is not part of what gets checked. Commas and semicolons both parse as separators in both forms, and the style question does not survive contact with deno fmt, which rewrites every separator to a semicolon in both declarations: the alias above was first typed with commas and came back normalised.

two interfaces with one name are one interface

Declare Person twice, and supply half of it:

Deno.test("two interfaces with one name are one interface", () => {
interface Person {
first: string;
}

interface Person {
last: string;
}

const ada: Person = { first: "Ada", last: "Lovelace" };
const half: Person = { first: "Ada" };

assertEquals(Object.keys(ada), ["first", "last"]);
assertStrictEquals(half.last, undefined);
});
Check programs/interfaces-and-type-aliases.test.ts
TS2741 [ERROR]: Property 'last' is missing in type '{ first: string; }' but required in type 'Person'.
const half: Person = { first: "Ada" };
~~~~
at file:///programs/interfaces-and-type-aliases.test.ts:32:9

'last' is declared here.
last: string;
~~~~
at file:///programs/interfaces-and-type-aliases.test.ts:28:5

error: Type checking failed.

This is declaration merging, and it is the feature that makes an interface open: two declarations, one type, requiring both properties, which is exactly what the refusal proves. Shield it:

Deno.test("two interfaces with one name are one interface", () => {
interface Person {
first: string;
}

interface Person {
last: string;
}

const ada: Person = { first: "Ada", last: "Lovelace" };

// @ts-expect-error: Property 'last' is missing in type '{ first: string; }' but required in type 'Person'.
const half: Person = { first: "Ada" };

assertEquals(Object.keys(ada), ["first", "last"]);
assertStrictEquals(half.last, undefined);
});
two interfaces with one name are one interface ... ok (315µs)

Merging is not overwriting. Redeclare a member with a different type, in a scratch file programs/merged.ts:

interface Merged {
id: string;
}

interface Merged {
id: number;
}

export const merged: Merged = { id: "a" };
Check programs/merged.ts
TS2717 [ERROR]: Subsequent property declarations must have the same type. Property 'id' must be of type 'string', but here has type 'number'.
id: number;
~~
at file:///programs/merged.ts:6:3

'id' was also declared here.
id: string;
~~
at file:///programs/merged.ts:2:3

error: Type checking failed.

So a merge can only add, and that constraint is what makes the feature safe enough to be on by default. Delete the scratch file.

an alias cannot be declared twice

The same experiment with type, in a scratch file programs/duplicated.ts:

type Doubled = { first: string };
type Doubled = { last: string };

export const doubled: Doubled = { first: "Ada" };
Check programs/duplicated.ts
TS2300 [ERROR]: Duplicate identifier 'Doubled'.
type Doubled = { first: string };
~~~~~~~
at file:///programs/duplicated.ts:1:6

TS2300 [ERROR]: Duplicate identifier 'Doubled'.
type Doubled = { last: string };
~~~~~~~
at file:///programs/duplicated.ts:2:6

Found 2 errors.

error: Type checking failed.

Both declarations are named, and neither wins. type introduces a binding in the type namespace the same way const introduces one in the value namespace, and a duplicate is a duplicate. That is the whole of closed, and everything below follows from it. Delete the scratch file.

extends computes a type, & records a constraint

When nothing conflicts, the two are interchangeable:

Deno.test("extends computes a type, & records a constraint", () => {
interface Base {
id: string;
}

interface Timestamped extends Base {
createdAt: number;
}

type TimestampedAlias = Base & { createdAt: number };

const fromInterface: Timestamped = { id: "a", createdAt: 1 };
const fromAlias: TimestampedAlias = fromInterface;

assertEquals(Object.keys(fromAlias), ["id", "createdAt"]);

type NumericId = { id: number } & Base;

// @ts-expect-error: Type 'number' is not assignable to type 'never'.
const numeric: NumericId = { id: 1 };

// @ts-expect-error: Type 'string' is not assignable to type 'never'.
const text: NumericId = { id: "a" };

assertEquals(Object.keys(numeric), ["id"]);
assertEquals(Object.keys(text), ["id"]);
});
extends computes a type, & records a constraint ... ok (231µs)

Then make them conflict, in a scratch file programs/conflicts.ts, with the same disagreement spelled all three ways:

interface Base {
id: string;
}

interface HasNumericId {
id: number;
}

export interface NumericId extends Base {
id: number;
}

export interface Both extends Base, HasNumericId {
extra: boolean;
}

export type BothAlias = Base & HasNumericId & { extra: boolean };

export const value: BothAlias = { id: 1, extra: true };
Check programs/conflicts.ts
TS2430 [ERROR]: Interface 'NumericId' incorrectly extends interface 'Base'.
Types of property 'id' are incompatible.
Type 'number' is not assignable to type 'string'.
export interface NumericId extends Base {
~~~~~~~~~
at file:///programs/conflicts.ts:9:18

TS2320 [ERROR]: Interface 'Both' cannot simultaneously extend types 'Base' and 'HasNumericId'.
Named property 'id' of types 'Base' and 'HasNumericId' are not identical.
export interface Both extends Base, HasNumericId {
~~~~
at file:///programs/conflicts.ts:13:18

TS2322 [ERROR]: Type 'number' is not assignable to type 'never'.
export const value: BothAlias = { id: 1, extra: true };
~~
at file:///programs/conflicts.ts:19:35

The expected type comes from property 'id' which is declared here on type 'BothAlias'
id: string;
~~
at file:///programs/conflicts.ts:2:3

error: Type checking failed.

Read the three errors as a comparison. Both extends forms report the conflict at the declaration and name what disagreed, TS2430 for one parent and TS2320 for two. The intersection's declaration reported nothing at all: BothAlias is legal to write, id inside it is string & number, which is never, and the complaint lands later, against every value that tries to satisfy it, since nothing is assignable to never, from the any, unknown, never page. That is the finding worth carrying away, and the shielded NumericId pins in the test show both value spellings failing the same way. extends computes a new object type, checking as it goes; & records two requirements and resolves members on demand. They are not two syntaxes for one operation, and when a conflict is possible, prefer the one that tells you. Delete the scratch file.

overriding, and why order matters in an intersection

An intersection keeps both parse members as an overload set. The value's parse returns a RegExp, and Loose, listed first, says object. Predict the instanceof:

Deno.test("overriding, and why order matters in an intersection", () => {
type Loose = { parse(input: string): object };
type Tight = { parse(input: string | number): RegExp };

const pattern = /a/;
const value = { parse: (_input: string | number) => pattern };

const looseFirst: Loose & Tight = value;

assertStrictEquals(looseFirst.parse("oak") instanceof RegExp, false);
});
Check programs/interfaces-and-type-aliases.test.ts
running 4 tests from ./programs/interfaces-and-type-aliases.test.ts
...
overriding, and why order matters in an intersection ... FAILED (8ms)

ERRORS

overriding, and why order matters in an intersection => ./programs/interfaces-and-type-aliases.test.ts:70:6
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- true
+ false

FAILURES

overriding, and why order matters in an intersection => ./programs/interfaces-and-type-aliases.test.ts:70:6

FAILED | 3 passed | 1 failed (10ms)

error: Test failed

A RegExp after all, whatever the type said. An interface's member replaces the member it overrides, so an extended interface has exactly one parse, where an intersection keeps both as an overload set, and the first one that accepts your argument wins. One object, two intersections differing only in order, and two different return types for the same call, both correct about the value, which returns a RegExp either way; only one of them says so. So B & A is the arrangement that resembles B extends A, even though A & B reads better: put the more specific type first. Correct the prediction and pin both orders:

Deno.test("overriding, and why order matters in an intersection", () => {
type Loose = { parse(input: string): object };
type Tight = { parse(input: string | number): RegExp };

const pattern = /a/;
const value = { parse: (_input: string | number) => pattern };

const looseFirst: Loose & Tight = value;
const tightFirst: Tight & Loose = value;

const asObject: object = looseFirst.parse("oak");
const asRegExp: RegExp = tightFirst.parse("oak");

// @ts-expect-error: Type 'object' is not assignable to type 'RegExp'.
const wrong: RegExp = looseFirst.parse("oak");

assertStrictEquals(asObject, asRegExp);
assertStrictEquals(wrong, asRegExp);
assertStrictEquals(looseFirst.parse("oak") instanceof RegExp, true);
});
overriding, and why order matters in an intersection ... ok (31µs)

only an interface, or a class, can say this

Deno.test("only an interface, or a class, can say this", () => {
interface Fluent {
add(text: string): this;
}

class Builder implements Fluent {
parts: string[] = [];

add(text: string): this {
this.parts.push(text);
return this;
}
}

class Loud extends Builder {
override add(text: string): this {
return super.add(text.toUpperCase());
}
}

const loud: Loud = new Loud().add("a").add("b");

assertEquals(loud.parts, ["A", "B"]);
});
only an interface, or a class, can say this ... ok (64µs)

this as a return type means whatever the receiver is, so chaining off a Loud yields a Loud rather than a Builder; annotate the return type Builder instead and the const loud: Loud line stops compiling, which is the entire reason the feature exists, because fluent APIs are unusable in a subclass without it. An alias cannot express it. Try, in a scratch file programs/notfluent.ts:

export type NotFluent = {
add(text: string): this;
};
Check programs/notfluent.ts
TS2526 [ERROR]: A 'this' type is available only in a non-static member of a class or interface.
add(text: string): this;
~~~~
at file:///programs/notfluent.ts:2:22

error: Type checking failed.

this is a type that depends on a receiver, and only a declaration that can be inherited has one. An alias is a name for a fixed type, so there is nothing for this to refer to. Delete the scratch file.

the one intersection worth writing by hand

Deno.test("the one intersection worth writing by hand", () => {
// deno-lint-ignore ban-types
type Size = "small" | "large" | (string & {});

type SizeByTheHint = "small" | "large" | Record<PropertyKey, never>;

const known: Size = "small";
const custom: Size = "12pt";

const kept: Extract<Size, "small"> = "small";

// @ts-expect-error: Type '"small"' is not assignable to type 'never'.
const lost: Extract<"small" | "large" | string, "small"> = "small";

// @ts-expect-error: Type '"12pt"' is not assignable to type 'SizeByTheHint'.
const broken: SizeByTheHint = "12pt";

assertStrictEquals(known, kept);
assertStrictEquals(custom, "12pt");
assertStrictEquals(lost, "small");
assert(typeof broken === "string");

// deno-lint-ignore ban-types
type Widen<T> = T & {};

type ThroughAlias = "small" | "large" | Widen<string>;

const literally: Extract<Size, "small"> = "small";

// @ts-expect-error: Type '"small"' is not assignable to type 'never'.
const throughAlias: Extract<ThroughAlias, "small"> = "small";

assertStrictEquals(literally, throughAlias);
});
the one intersection worth writing by hand ... ok (32µs)

Size accepts "small", "large", and any other string, which "small" | "large" | string also does, and the difference is that the plain union is string: the literal members are absorbed, because each is already a string, which Extract shows by keeping "small" out of Size and finding nothing at all in the collapsed union. That absorption is what makes the plain union useless for the job people actually want here, an editor that suggests "small" and "large" while still accepting "12pt". Intersecting with {} produces a type assignable both ways with string that is not the same type, so the union keeps three members to suggest. Deno's linter flags the {} and its hint is wrong for this case, which is why the line carries an ignore: the object types page showed Record<PropertyKey, never> as the right advice almost everywhere, and here it is a different type, since "12pt" is not an object with no properties, so SizeByTheHint refuses it. And the trick only works written out: Widen<string> is the same intersection reached through a generic alias, and instantiating it reduces back to string, so the union collapses again, as the shielded throughAlias pin proves. Two spellings of one type, one of which survives, worth knowing before you tidy the idiom into a named helper.

reading a utility type by probing it

Deno.test("reading a utility type by probing it", () => {
const present: NonNullable<string | undefined> = "a";

// @ts-expect-error: Type 'undefined' is not assignable to type '{}'.
const nothing: NonNullable<unknown> = undefined;

assertStrictEquals(present, "a");
assertStrictEquals(nothing, undefined);
});
reading a utility type by probing it ... ok (13µs)

NonNullable<T> is defined as T & {}, the same intersection again put to its most ordinary use: {} accepts everything except null and undefined, so intersecting with it removes exactly those. The probe is the point. You cannot read a type's definition from your editor as easily as a function's, but you can ask it a question whose answer distinguishes the candidates: if NonNullable<T> were the older T extends null | undefined ? never : T, then NonNullable<unknown> would be unknown and the shielded line would compile. It does not, so the definition is the intersection.

four things only an alias can name

Deno.test("four things only an alias can name", () => {
type Ids = string | number;
type Pair = [name: string, age: number];
type Sku = string;
type Person = { first: string; last?: string };

const ids: Ids[] = ["a", 1];
const pair: Pair = ["Ada", 36];
const sku: Sku = "veg-1";
const partial: Partial<Person> = { first: "Ada" };

assertEquals(ids, ["a", 1]);
assertStrictEquals(pair[0], "Ada");
assertStrictEquals(sku, "veg-1");
assertStrictEquals(partial.last, undefined);
});
four things only an alias can name ... ok (38µs)

A union, a tuple, a primitive, and a mapped type. An interface can describe none of them, because an interface declares an object shape and each of these is something else; Partial<T> is a mapped type and so is Record<K, V>, which is why the object types page can say that Record and an index signature differ under keyof. An interface can still extend an alias, as long as what the alias names is an object type. Probe the boundary in a scratch file programs/fromunion.ts:

type Either = { a: number } | { b: number };

export interface FromUnion extends Either {
c: number;
}

export interface FromRecord extends Record<string, number> {
c: number;
}
Check programs/fromunion.ts
TS2312 [ERROR]: An interface can only extend an object type or intersection of object types with statically known members.
export interface FromUnion extends Either {
~~~~~~
at file:///programs/fromunion.ts:3:36

error: Type checking failed.

One error, not two. A union is refused because there is no single set of members to inherit, and the Record line passes, because statically known turns out to be a low bar: it means the members can be enumerated at declaration time, and an index signature counts as one member. Delete the scratch file.

being open is a feature, and it reaches the standard library

Put an augmentation in a scratch file programs/augment.ts and run it:

declare global {
interface ArrayConstructor {
everySecond<T>(array: T[]): T[];
}
}

Array.everySecond = <T>(array: T[]): T[] =>
array.filter((_item, index) => index % 2 === 0);

console.log(Array.everySecond(["a", "b", "c", "d", "e"]));
[ "a", "c", "e" ]

ArrayConstructor is the interface describing Array as a value, declared in TypeScript's own library files, and merging into it from your code is not a loophole; it is how those files are organised. A new method arrives as an increment to an existing interface in a new lib file rather than as an edit to the old one, which is how Array.fromAsync was typed before the core declarations carried it, and the same mechanism patches a dependency whose types are wrong. The hazard is the mirror of the feature: a merge you did not write can add members to an interface you thought you controlled, and nothing in your file shows it happened. That is a real argument for type as the default, and an argument for interface in exactly the cases where being extended is the point. Delete the scratch file, so the suite's arrays stay undecorated.

an alias goes inline when it is used once

Deno.test("an alias goes inline when it is used once", () => {
function describe(product: { sku: string }): string {
return `product ${product.sku}`;
}

assertStrictEquals(describe({ sku: "veg-1" }), "product veg-1");
});
an alias goes inline when it is used once ... ok (21µs)

An interface cannot go there. If the shape is used twice, name it; if it is used once, an inline object type is less to read and less to maintain.

The whole entry

Run the whole reference suite:

Check programs/any-unknown-never.test.ts
Check programs/arrays.test.ts
Check programs/assignment.test.ts
Check programs/async-functions.test.ts
Check programs/async-iteration.test.ts
Check programs/branching.test.ts
Check programs/buffers-and-views.test.ts
Check programs/classes.test.ts
Check programs/closures.test.ts
Check programs/conversion-and-coercion.test.ts
Check programs/dates-and-times.test.ts
Check programs/designing-error-types.test.ts
Check programs/destructuring.test.ts
Check programs/equality.test.ts
Check programs/errors-and-exceptions.test.ts
Check programs/functions.test.ts
Check programs/generators.test.ts
Check programs/interfaces-and-type-aliases.test.ts
Check programs/iterables-and-iterators.test.ts
Check programs/iterator-helpers.test.ts
Check programs/json.test.ts
Check programs/loops.test.ts
Check programs/maps.test.ts
Check programs/matching-and-replacing.test.ts
Check programs/module-specifiers.test.ts
Check programs/modules.test.ts
Check programs/mutating-arrays.test.ts
Check programs/nothing-twice.test.ts
Check programs/numbers.test.ts
Check programs/object-types.test.ts
Check programs/objects-as-dictionaries.test.ts
Check programs/objects.test.ts
Check programs/ordering-and-sorting.test.ts
Check programs/parameters-and-arguments.test.ts
Check programs/private-class-members.test.ts
Check programs/promise-combinators.test.ts
Check programs/promises.test.ts
Check programs/prototypes-and-inheritance.test.ts
Check programs/read-only.test.ts
Check programs/regular-expressions.test.ts
Check programs/scope-and-declarations.test.ts
Check programs/sentinels.test.ts
Check programs/sets.test.ts
Check programs/strings.test.ts
Check programs/subclassing.test.ts
Check programs/symbols.test.ts
Check programs/tagged-templates.test.ts
Check programs/text-and-characters.test.ts
Check programs/the-event-loop.test.ts
Check programs/the-value-of-this.test.ts
Check programs/transforming-arrays.test.ts
Check programs/truthiness.test.ts
Check programs/typed-arrays.test.ts
Check programs/unicode-in-patterns.test.ts
Check programs/unions-and-narrowing.test.ts
Check programs/values-and-references.test.ts
Check programs/weak-collections.test.ts
Check programs/what-a-type-is.test.ts
running 10 tests from ./programs/any-unknown-never.test.ts
...
running 13 tests from ./programs/arrays.test.ts
...
running 9 tests from ./programs/assignment.test.ts
...
running 10 tests from ./programs/async-functions.test.ts
...
running 11 tests from ./programs/async-iteration.test.ts
...
running 10 tests from ./programs/branching.test.ts
...
running 12 tests from ./programs/buffers-and-views.test.ts
...
running 11 tests from ./programs/classes.test.ts
...
running 6 tests from ./programs/closures.test.ts
...
running 11 tests from ./programs/conversion-and-coercion.test.ts
...
running 13 tests from ./programs/dates-and-times.test.ts
...
running 10 tests from ./programs/designing-error-types.test.ts
...
running 14 tests from ./programs/destructuring.test.ts
...
running 11 tests from ./programs/equality.test.ts
...
running 10 tests from ./programs/errors-and-exceptions.test.ts
...
running 11 tests from ./programs/functions.test.ts
...
running 12 tests from ./programs/generators.test.ts
...
running 9 tests from ./programs/interfaces-and-type-aliases.test.ts
one value, two names ... ok (145µs)
two interfaces with one name are one interface ... ok (149µs)
extends computes a type, & records a constraint ... ok (39µs)
overriding, and why order matters in an intersection ... ok (21µs)
only an interface, or a class, can say this ... ok (62µs)
the one intersection worth writing by hand ... ok (30µs)
reading a utility type by probing it ... ok (11µs)
four things only an alias can name ... ok (23µs)
an alias goes inline when it is used once ... ok (16µs)
running 14 tests from ./programs/iterables-and-iterators.test.ts
...
running 12 tests from ./programs/iterator-helpers.test.ts
...
running 11 tests from ./programs/json.test.ts
...
running 14 tests from ./programs/loops.test.ts
...
running 15 tests from ./programs/maps.test.ts
...
running 15 tests from ./programs/matching-and-replacing.test.ts
...
running 6 tests from ./programs/module-specifiers.test.ts
...
running 12 tests from ./programs/modules.test.ts
...
running 10 tests from ./programs/mutating-arrays.test.ts
...
running 11 tests from ./programs/nothing-twice.test.ts
...
running 15 tests from ./programs/numbers.test.ts
...
running 15 tests from ./programs/object-types.test.ts
...
running 14 tests from ./programs/objects-as-dictionaries.test.ts
...
running 13 tests from ./programs/objects.test.ts
...
running 12 tests from ./programs/ordering-and-sorting.test.ts
...
running 11 tests from ./programs/parameters-and-arguments.test.ts
...
running 11 tests from ./programs/private-class-members.test.ts
...
running 11 tests from ./programs/promise-combinators.test.ts
...
running 11 tests from ./programs/promises.test.ts
...
running 12 tests from ./programs/prototypes-and-inheritance.test.ts
...
running 12 tests from ./programs/read-only.test.ts
...
running 13 tests from ./programs/regular-expressions.test.ts
...
running 9 tests from ./programs/scope-and-declarations.test.ts
...
running 8 tests from ./programs/sentinels.test.ts
...
running 13 tests from ./programs/sets.test.ts
...
running 10 tests from ./programs/strings.test.ts
...
running 11 tests from ./programs/subclassing.test.ts
...
running 10 tests from ./programs/symbols.test.ts
...
running 8 tests from ./programs/tagged-templates.test.ts
...
running 10 tests from ./programs/text-and-characters.test.ts
...
running 9 tests from ./programs/the-event-loop.test.ts
...
running 10 tests from ./programs/the-value-of-this.test.ts
...
running 13 tests from ./programs/transforming-arrays.test.ts
...
running 9 tests from ./programs/truthiness.test.ts
...
running 14 tests from ./programs/typed-arrays.test.ts
...
running 11 tests from ./programs/unicode-in-patterns.test.ts
...
running 13 tests from ./programs/unions-and-narrowing.test.ts
...
running 13 tests from ./programs/values-and-references.test.ts
...
running 9 tests from ./programs/weak-collections.test.ts
...
running 7 tests from ./programs/what-a-type-is.test.ts
...

ok | 650 passed | 0 failed (1s)

Nine tests, and the practice is short. Default to type, because most shapes are closed, most named types are not object types anyway, and a declaration that can be merged into from anywhere is a poor default for something you own. Reach for interface when being extended is the point: a library's public shape, a class contract, and any declaration you expect somebody to augment, with implements and this both wanting one, which the typing classes page goes into. Use extends rather than & whenever a conflict is possible, because one reports the problem at the declaration and names it, and the other hands you a type no value can satisfy and blames the value. Put the more specific type first in an intersection. Write an alias inline when it is used once. And probe a type you are unsure about, assigning a value that only one of the candidate definitions would accept, and letting the error tell you which one you have.