はじめに
※この記事は、巻末のコードをAIに解説してもらったものです。
JavaScriptに型が欲しい、と思ったことはありませんか?
でもTypeScriptはビルドが必要だし、
ランタイムで保証されるわけでもない。
最近話題の「Type Stripping」も、結局は型を消して実行しているに過ぎません。
そこで今回紹介するのが、実行時に型を強制する新しい書き方です。
それが――
Types as Properties
です。
型は「値として代入できる」
まずはこれを見てください。
const locals = {
num: Type.any = 1,
str: Type.any = "Hi",
flg: Type.any = true
};
一見ただの代入ですが、ここで重要なのは
Type.any = 値という構文そのものが「型付け」になっている
という点です。
しかも any なので何でも通ります。
setterで型チェック
次に number 型を定義します。
Object.defineProperty(Type, "number", {
set: value => {
if(typeof value !== "number")
throw new TypeError(`${typeof value} is not number`);
}
});
これだけで…
this.number = 1; // OK
this.number = "hi"; // ❌ error
ランタイム型チェックが完成しました。
配列も型付けできる
this.numbers = [1, 2, 3]; // OK
this.numbers = [1, null]; // ❌ error
さらに…
[
this.number,
this.number,
this.number
] = [4, 5, 6];
分割代入すら型チェック対象になります。
オブジェクトも安全に
this.accessor({
x: this.number = 0,
y: this.number = 0
});
これで
point.x = "hello"; // ❌
のような不正代入も防げます。
関数型まで書ける
Type.def({
Add: number => number => number
});
使い方:
const add = Type.Add((x, y) => x + y);
add(1, 2); // 3
add(1, "hi"); // ❌
ついにJavaScriptで
関数シグネチャが実行時に保証される時代へ
まとめ
この仕組みを使えば:
- 型は「宣言」ではなく「代入」で書ける
- setterで実行時検証
- 分割代入にも対応
- 関数型もサポート
つまり…
JavaScriptそのものが型システムになる
※追記(重要)
この記事はエイプリルフールネタです。
ただしコードは実際に動きます。
懸念点(現実)
冷静に見るといくつか問題があります:
1. 可読性がかなり低い
x: this.number = 1
は普通のJS開発者にはほぼ読めません。
2. 副作用が強すぎる
Type.number = value
が「検証だけして値を保持しない」というのは直感に反します。
3. toString()依存の関数型パース
number => number => number
を文字列分解しているため、
- minifyで壊れる
- 書き方に依存する
という問題があります。
4. 実行コスト
すべての代入でチェックが走るため、
パフォーマンスはそれなりに犠牲になります。
5. TypeScriptの完全下位互換ではない
- 静的解析できない
- IDE補完が効かない
それでも面白い理由
このコードの本質は:
型を「メタ情報」ではなく「振る舞い」として扱っている
点です。
これはかなり本質的で、
- Proxy
- バリデーションライブラリ
- ランタイム型チェック
などに繋がる発想です。
結論
ネタとしてはこう言えます:
JavaScriptに型は不要です。
なぜなら、もう書けるからです。
(ただし正気は失います)
おまけ(全コード)
class Type {
static get any() {
return void 0;
}
static set any(value) {}
}
class AnyExample {
static main() {
try {
const locals = {
num: Type.any = 1,
str: Type.any = "Hi",
flg: Type.any = true
};
console.table(locals);
locals.num = "no error";
console.log(locals.num);
} catch(e) {
console.error(e.message);
}
}
}
AnyExample.main();
Object.defineProperty(Type, "number", {
get: () => 0,
set: value => {
if(typeof value !== "number")
throw new TypeError(`${typeof value} is not number`);
},
configurable: true,
enumerable: true
});
class NumberExample extends Type {
static main() {
try {
const locals = {
zero: this.number,
one: this.number = 1,
two: this.number = 2
};
console.table(locals);
this.number = "string is not number"; // error
} catch(e) {
console.error(e.message);
}
}
}
NumberExample.main();
Object.defineProperties(Type, {
Array: {
get: () => [],
set: values => {
if(!Array.isArray(values))
throw new TypeError(`${typeof values} is not Array`);
},
configurable: true,
enumerable: true
},
numbers: {
get: () => [0],
set: values => {
values.forEach((value, i) => {
if(typeof value !== "number")
throw new TypeError(`[${i}]: ${typeof value} is not number`);
});
},
configurable: true,
enumerable: true
}
});
class ArrayExample extends Type {
static main() {
try {
const locals = {
basicArray: this.Array = [0, "", false],
typedNumberArray: this.numbers = [1, 2, 3],
destructuredTuple: [
this.number,
this.number,
this.number
] = [4, 5, 6],
objectDestructuredTuple: {
0: this.number,
1: this.number,
2: this.number
} = [7, 8, 9],
hybridArray: [
this.any,
...this.numbers
] = [null, 10, 11]
};
console.table(locals);
//this.Array = "string is not Array"; // error
this.numbers = [null]; // [0]: object is not number
} catch(e) {
console.error(e.message);
}
}
}
ArrayExample.main();
Type.of = value =>
value === null ? "null"
: typeof value !== "object" ? typeof value
: value.constructor && value.constructor.name ||
//: value?.constructor?.name ||
Object.prototype.toString.call(value).slice(8, -1);
class Property extends Type {
constructor() {
super();
}
accessor(props) {
for(const [name, initValue] of Object.entries(props)) {
let descriptor = null;
let validate = null;
let _value = initValue;
const typeName = Type.of(initValue);
const self = this;
if(Type.of(initValue.get) === "function" ||
Type.of(initValue.set) === "function") {
descriptor = {
configurable: true,
enumerable: true,
...initValue
};
} else {
if(initValue === null)
validate = value => value === null;
else if(typeof initValue !== "object")
validate = value => Type.of(value) === typeName;
else
validate = value => value instanceof self[typeName];
descriptor = {
get: () => _value,
set: value => {
if(!validate(value))
throw new TypeError(`${Type.of(value)} is not ${typeName}`);
_value = value;
},
configurable: true,
enumerable: true
};
}
Object.defineProperty(this, name, descriptor);
}
}
readonly(props) {
for(const [name, _value] of Object.entries(props)) {
let descriptor = null;
if(Type.of(_value.get) === "function")
descriptor = {
configurable: true,
enumerable: true,
..._value
};
else
descriptor = {
get: () => _value,
set: value => {
throw TypeError(`constants cannot be changed`);
},
configurable: true,
enumerable: true
};
Object.defineProperty(this, name, descriptor);
}
}
}
Type.def = function(defs) {
for(const [name, value] of Object.entries(defs)) {
let descriptor = null;
if(Type.of(value) === "function") {
let paramTypes = value.toString().split(" => ");
let returnType = paramTypes.pop();
const self = this;
descriptor = {
get: () => function(proc) {
return function(...params) {
if(params.length !== paramTypes.length)
throw Error(`Parameter mismatch: expected ${paramTypes.length} got ${params.length}`);
params.forEach((param, i) => {
self[paramTypes[i]] = param;
});
const result = proc.call(this, ...params);
self[returnType] = result;
return result;
};
},
configurable: true,
enumerable: true
};
} else {
descriptor = value;
}
Property.prototype.readonly.call(this, {[name]: descriptor});
}
};
Object.defineProperty(Type.prototype, "number", Object.getOwnPropertyDescriptor(Type, "number"));
class Point extends Property {
constructor(x = 0, y = 0) {
super();
this.accessor({x: this.number = x});
this.accessor({y: this.number = y});
}
}
Type.def({
Point: {
get: () => Point,
set: value => {
if(!(value instanceof Point))
throw new TypeError(`${Type.of(value)} is not Point`);
}
}
});
class PointExample extends Type {
static main() {
try {
const locals = new Property();
locals.accessor({
defaultPoint: this.Point = new Point(),
initializedPoint: {
x: this.number,
y: this.number
} = new Point(1, 1)
});
console.table(locals);
locals.defaultPoint.x = 1;
console.table(locals.defaultPoint);
//this.Point = null; // null is not Point
locals.defaultPoint.y = false; // boolean is not number
} catch(e) {
console.error(e.message);
}
}
}
PointExample.main();
class TypedefExample extends Type {
static main() {
try {
this.def({
Add: number => number => number,
One: this.number = 1,
Two: this.number = 2
});
const add = this.Add((x, y) => x + y);
console.log(add(this.One, this.Two)); // 3
console.log(add(this.One, "string is not number")); // error
} catch(e) {
console.error(e.message);
}
}
}
TypedefExample.main();