Почему утка набирается для классов в TypeScript

Похоже, что в TypeScript это абсолютно нормально (с точки зрения компилятора), чтобы иметь такой код:

class Vehicle {
    public run(): void { console.log('Vehicle.run'); }
}

class Task {
    public run(): void { console.log('Task.run'); }
}

function runTask(t: Task) {
    t.run();
}

runTask(new Task());
runTask(new Vehicle());

Но в то же время я ожидал бы ошибки компиляции, потому что Vehicle и Task не имеют ничего общего.

И разумные способы использования могут быть реализованы с помощью явного определения интерфейса:

interface Runnable {
    run(): void;
}

class Vehicle implements Runnable {
    public run(): void { console.log('Vehicle.run'); }
}

class Task implements Runnable {
    public run(): void { console.log('Task.run'); }
}

function runRunnable(r: Runnable) {
    r.run();
}

runRunnable(new Task());
runRunnable(new Vehicle());

... или общий родительский объект:

class Entity {
    abstract run(): void;
}

class Vehicle extends Entity {
    public run(): void { console.log('Vehicle.run'); }
}

class Task extends Entity {
    public run(): void { console.log('Task.run'); }
}

function runEntity(e: Entity) {
    e.run();
}

runEntity(new Task());
runEntity(new Vehicle());

И да, для JavaScript это абсолютно нормально, чтобы иметь такое поведение, потому что нет классов и никакого компилятора вообще (только синтаксический сахар) и утиная типизация является естественным для языка. Но TypeScript пытается ввести статические проверки, классы, интерфейсы и т.д. Однако утиная типизация для экземпляров классов выглядит довольно запутанной и подверженной ошибкам, на мой взгляд.

Ответ 1

Вот как работает структурная типизация. Typescript имеет систему структурного типа, чтобы наилучшим образом подражать тому, как работает Javscript. Поскольку Javascript использует утиную печать, любой объект, который определяет контракт, может использоваться в любой функции. Typescript просто пытается проверять утиную печать во время компиляции, а не во время выполнения.

Однако ваша проблема будет проявляться только для тривиальных классов, как только вы добавляете рядовых, классы становятся несовместимыми, даже если они имеют одинаковую структуру:

class Vehicle {
    private x: string;
    public run(): void { console.log('Vehicle.run'); }
}

class Task {
    private x: string;
    public run(): void { console.log('Task.run'); }
}

function runTask(t: Task) {
    t.run();
}

runTask(new Task());
runTask(new Vehicle()); // Will be a compile time error

Это поведение также позволяет вам явно не реализовывать интерфейсы, например, вы можете определить интерфейс для параметра inline, и любой класс, который удовлетворяет контракту, будет совместим, даже если они явно не реализуют какой-либо интерфейс:

function runTask(t: {  run(): void }) {
    t.run();
}

runTask(new Task());
runTask(new Vehicle());

В личном случае, исходя из С#, сначала это казалось безумным, но когда дело доходит до расширяемости, этот способ проверки типов позволяет значительно повысить гибкость, как только вы привыкнете к нему, вы увидите преимущества.

Ответ 2

Теперь с помощью TypeScript можно создавать номинальные типы, которые позволяют различать типы по контексту. Пожалуйста, рассмотрите следующий вопрос:

Атомная дискриминация типов (номинальные атомные типы) в TypeScript

С этим пример:

export type Kilos<T> = T & { readonly discriminator: unique symbol };
export type Pounds<T> = T & { readonly discriminator: unique symbol };

export interface MetricWeight {
    value: Kilos<number>
}

export interface ImperialWeight {
    value: Pounds<number>
}

const wm: MetricWeight = { value: 0 as Kilos<number> }
const wi: ImperialWeight = { value: 0 as Pounds<number> }

wm.value = wi.value;                  // Gives compiler error
wi.value = wi.value * 2;              // Gives compiler error
wm.value = wi.value * 2;              // Gives compiler error
const we: MetricWeight = { value: 0 } // Gives compiler error