はじめに
Q. let h = null
について、TypeScriptはどのような型を推論するでしょうか?
(▶ をクリックすると回答が表示されます)
A. any 型 (`let h: any`) となる[^1]
[^1]: `tsconfig.json`において`strictNullChecks`が`true`かつ、`noImplicitAny`が`false`となっている場合は null 型になります。
こちらの問いは、プログラミング TypeScript ――スケールする JavaScript アプリケーション開発に記載されていた問いになります。
皆さんは正解しましたか?
(ちなみに、const h = null
とした場合、h
は null 型となります)
本記事では、なぜこのような結果になるのか解説したいと思います。
前提
-
バージョン
$ tsc -v Version 4.1.3
-
tsconfig.json
において"strictNullChecks": true
かつ、"noImplicitAny": true
1
{
"compilerOptions": {
"strictNullChecks": true,
"noImplicitAny": true,
// or
"strict": true, // strictがtrueだと、上記のオプション含めた複数のオプションがtrueになります
}
}
解説
結論
null 型、undefined 型を、 let
や var
といった後で変更可能な方法で宣言した場合、any 型に拡張されます。
これは型の拡大(type widening)
と呼ばれます2。
null や undefined で型の拡大を利用するパターンは稀ですが、リテラル型からプリミティブ型への拡張はよく利用されるかと思います。
例えば以下のように宣言した場合、リテラル型である1
はconst
で宣言した場合はそのまま1型
になり、let
で宣言した場合はプリミティブ型であるnumber
型へ拡張されます。
const one = 1; // 'one' has type: 1 let num = 1; // 'num' has type: number
引用: TypeScript-New-Handbook/Widening-and-Narrowing
以下、詳しく説明します。
プリミティブ型とリテラル型
型の拡大に触れる前に、TypeScript で扱う型について整理しておきます。
(TypeScript で扱う型についてはTypeScript の型入門が詳しいです)
簡単に説明しますと、
- プリミティブ型は、
string
,number
,bigint
,boolean
,undefined
,symbol
の 6 種類を指します3 - リテラル型は、
string
,number
,boolean
型の値そのものを指す型であり、それ以外の値を受け入れることができません
const one = 1; // one は 1型 というリテラル型
let foo: "foo" = "foo";
foo = "bar"; // ERROR: Type '"bar"' is not assignable to type '"foo"'.ts(2322)
型の拡大(type widening)
型の拡大により、変数を変更可能な方法で宣言したときに、以下のように型が変化します。
- null 型、undefined 型を any 型として扱う
-
strictNullChecks
のtrue/false
によって挙動が異なる
-
- リテラル型をプリミティブ型として扱う
- Enum 型のメンバは、それを含む Enum 型として扱う
変数を変更可能な方法
とは、var
やlet
での変数宣言だけでなく、オブジェクトや配列の宣言も含みます(知っての通り、JavaScript のこれらは変更可能なので)。
1. null 型、undefined 型を any 型として扱う
strict: true
の場合、let
やvar
で宣言した null 型や undefined 型は any に拡大されます。
しかし、オブジェクトや配列の要素として宣言した null 型は拡大されません。
// strictNullChecks: true, noImplicitAny: true
// or strict: true
let h = null; // any type
const i = null; // null type
let j = undefined; // any type
const k = undefined; // undefined type
const l = {
m: null,
n: undefined,
};
// const l = {
// m: null,
// n: undefined,
// };
const o = [null, undefined];
// const o: (null | undefined)[]
strictNullChecks: true
、noImplicitAny: false
の場合、let
やvar
で宣言した null 型や undefined 型も拡大されず、 null 型、undefined 型のままになります4。
// strictNullChecks: true, noImplicitAny: false
let h = null; // null type
const i = null; // null type
let j = undefined; // undefined type
const k = undefined; // undefined type
// object, arrayの挙動は上と同様
strictNullChecks: false
の場合、null 型、undefined 型は明示的に宣言しない限り any 型へと拡大されます。
// strictNullChecks: false
let h = null; // any type
const i = null; // any type
let j = undefined; // any type
const k = undefined; // any type
const l = {
m: null,
n: undefined,
};
// const l = {
// m: any,
// n: any,
// };
const o = [null, undefined];
// const o: any[]
let p: null = null; // null type
const q: undefined = undefined; // undefined type
ただし、null 型、undefined 型で宣言された変数がそのスコープを離れると、明確な型が割り当てられます。
function x() {
// function x(): null
let a = null; // let a: any
return a;
}
function y() {
// function y(): string
let a = null; // let a: any
a = 3; // let a: any
a = "b"; // let a: any
return a;
}
let z = y(); // let z: string
2. リテラル型をプリミティブ型として扱う
変数を変更可能な方法で宣言したときに、以下のように拡大されます。
const h = 1; // 1 type (literal type)
let i = 1; // number type (primitive type)
// letの場合も同様
const j = {
k: "k",
l: 0,
m: true,
};
// const j: {
// k: string;
// l: number;
// m: boolean;
// }
const n = ["n", 0, false];
// const n: (string | number | boolean)[]
ただし、2 つの注意点があります。
- リテラル型の拡大は、式によるリテラル型にのみ発生し、型によるリテラル型では発生しない
- リテラル型の拡大は、宣言されたリテラル型が変更可能な場所に到達するたびに発生する
式によって宣言されたリテラル型
はTypeScript-New-Handbook/Widening-and-Narrowingでは、fresh literal type
と呼ばれ、リテラル型とは区別されています。
const o = 1; // `1`という式によってリテラル型を宣言 (= fresh literal type)
const p: 1 = 1; // `: 1`という型によってリテラル型を宣言 (= literal type)
const q = {
o: o,
p: p,
};
// オブジェクトのプロパティや配列は変更可能なので、
// 式で宣言されたリテラル型はnumber型に拡大される
// ただし、型で宣言されたリテラル型は拡大されない
// const q: {
// o: number;
// p: 1;
// }
const r = [o]; // const r: number[]
const s = [p]; // const r: 1[]
// もちろん、letやvarで宣言した場合も、上記と同様に拡大される
let t = o; // let t: number
let u = p; // let u: 1
3. Enum 型のメンバは、それを含む Enum 型として扱う
enum A {
B,
C,
}
const b = A.B; // const b: A.B
let c = A.C; // let c: A
let d = b; // let d: A
型を拡大したくない場合
const アサーションを用いて、型アサーション5を行うことで、型の拡大を抑えることが出来ます。
また、オブジェクトや配列に対して const アサーションを用いると、再帰的にReadOnlyに指定します。
let a = 1; // let a: number
let b = 1 as const; // let b: 1
let c = { x: 1 }; // let c: { x: number; }
let d: { x: 1 } = { x: 1 }; // let d: { x: 1; }
let e = { x: 1 } as const; // let e: { readonly x: 1; }
最後に
上の挙動は、TypeScript に触れていれば感覚で理解できていると思います。
しかし、挙動を明確に把握することで、予期せぬ型推論を防ぐことができると思います。
この記事が皆さんのお役に立てれば幸いです。
参考文献
- プログラミング TypeScript ――スケールする JavaScript アプリケーション開発
- TypeScript Deep Dive 日本語版
- TypeScript の型推論詳説
- TypeScript の型入門
-
↩"strictNullChecks": false
の場合、const
で定義した null も、any 型になります。
また、"strictNullChecks": true
かつ"noImplicitAny": false
の場合、let
で定義した null はconst
と同様に null 型になります。
各オプションについては以下を参照: -
プログラミング TypeScript ――スケールする JavaScript アプリケーション開発の中で
type widening
を型の拡大
と訳していたため、それに則っています。 ↩ -
参照: MDN Web Docs 用語集: ウェブ関連用語の定義 | Primitive (プリミティブ)
ここでは null 型をプリミティブ型に含んでいませんが、記事によっては含んでいるものもあります。 ↩ -
どうしてこのような挙動の違いがあるのかまで、調べきれてません。ご存知の方いらっしゃいましたらご教示いただけますと幸いです。 ↩