TypeScript 入門ノート
TypeScriptとは
TypeScriptはJavaScriptのスーパーセット(superset)、jsの機能は全部含まれており、その上で静的型付け機能などを追加しています。
例えで言えば、less/sassがCSSを拡張していると同じく、tsがjsを拡張している。ブラウザーなどの実行環境では、tsのコードが実行されるわけではなく、less/sassがcssにトランスパイルされるように、tsのコードもjsにトランスパイルされてから実行する(今後は変わるかもしれませんが)。
なぜtsが必要かというと、jsは動的型付け言語で、データの型検知は実行時(runtime)にされるため、様々なエラーが実行時でしか分からないという弱点があります。これも初心者がpython、jsとかでプログラミングを始める時に結構悩むところでしょう。ここで、tsの一番重要な役割というのは、Java、cとかの静的型付け言語のように、コンパイルされる前に型検知が行われ、エラーがある場合コンパイルが失敗するため、実行時の前に問題を未然に防ぐところにあります。
もちろん、型検知だけでなく、tsは他のバックエンド言語(Java、Go、C++など)の機能を提供しています。例えば、Enum、ジェネリックタイプ、タイプキャスト、タイプ宣言(declare)、インターフェース、ネームスペースなど。従来のjsの書き方を豊富に拡張できています。
より「型・タイプ」を理解しやすくするために、集合(set)の観点から、**タイプとは、変数に「与えられる値=可能性」の集合(set of values)**として考えると良いでしょう。
ts環境用意
インストールは公式のホームページの通り、環境が用意できますが、コマンドにまとめると:
npm i -g typescript ts-node
cd <foldername> && tsc --init
touch index.ts
ts-node index.ts
もちろん、公式のプレーグラウンドもおすすめのクラウド環境です。好きなエディターでやりたい場合はローカルで良いでしょう。
データタイプ
jsからのタイプ
まずjsのデータタイプを全部含めています:
let str: string = 'hoge'
let num: number = 123
let bool: boolean = false
let u: undefined = undefined
let n: null = null
let obj: object = {a: 456}
let big: bigint = 100n
let sym: symbol = Symbol('hoge')
通常では同じタイプの変数にしか値を付与できませんが、例外として、null
とundefined
がすべてのタイプの「子タイプ」のため、他のタイプに与えることが可能。
let str:string = 'hoge'
str = null // ok
str= undefined // ok
これを防ぐために、tsconfig
ファイルに、"strickNullChecks":true
にすれば、null
とundefined
が自分自身か、void
に与えることしかできなくなります。
また、number
とbigint
は両方数字ですが、タイプ上の互換性・包括関係がありません。
配列
配列もjsからですが、タイプ定義の仕方が違うので切り離して説明します。
定義は基本2つのやり方があります:
let arr:string[] = ['a','b']
let arr2:Array<number> = [1, 2]
ユニオン配列:
let arr3: (number | string)[] // 要素が数字または文字列の配列
arr3 = ['a', 1]
オブジェクト配列:
let arr4: {name:string,age:number}[] = [{name:'taro', age:30}]
// 通常は別途Typeまたはinterfaceで定義するが、また後で説明する
interface User {
name: string
age: number
}
let arr5: User[] = [{name:'taro', age:30}]
関数
関数も書き方によって若干タイプ定義の仕方が違います:
// シンプルな関数はこれで良い
function add(x: number, y: number): number {
return x + y
}
// 若干読みにくい
const addFn: (x: number, y: number) => number = function (x, y) {
return x + y
}
// 前より少しは読みやすくなる
const addFn = function (x: number, y: number): number {
return x + y
}
// 抽出してtypeまたはinterfaceで定義する
interface addFunc {
(x: number, y: number) : number
}
const addFn: addFunc = (x, y) => x + y
オプション引数とデフォルト値
jsとたいした変わりがありません:
function getFullname(first:string, last:string, middle?:string) {
return first + ' ' + middle + ' ' + last
}
function getFullname(first:string, last:string, middle:string = '') {
return first + ' ' + middle + ' ' + last
}
ただ、同時にオプショナルの?
とデフォルト値を設定することができません(矛盾しています)。
引数収集(collect)
訳語がよくわかりませんが、要は...
演算子で残りの引数をまとめることです:
// anyはなんでも良いタイプ、また後で説明
function pushToArray(arr:any[], ...items: any[]) {
items.forEach(item => {
arr.push(item)
})
}
オーバーロード
アニメではないですw。Javaとかでもみられますが、同じ関数名で違う引数とリターン値のパターンを複数定義すると、それぞれの引数パターンで同じ関数を呼び出すことができます。
type Addable = number | string
function add(x:number, y:number): number
function add(x: string, y: string): string
function add(x: string, y: number): string
function add(x:string, y:string): string
// 上は全部オーバーロード定義
function add(x:Addable, y:Addable) {
if (typeof x === 'string' || typeof y === 'string') {
return x.toString() + y.toString()
}
return x + y
}
Tuple
訳語がよくわかりませんのでそのまま。Pythonにはあるデータタイプですが、要は要素のタイプと個数が固定の配列。
type Point = [number, number]
p1: Point = ['hoge', 2] // x
p2: Point = [2,3] // ok
p3: Point = [2,3,4] // x
分割代入
実質配列ですから、分割代入(destructuring assignment)が可能。ただ個数もあわないといけません:
type geoData = [number, number]
const location = [1234.45678, 87656.8909]
let [lat, lng] = location // ok
let [lat, lng, name] = location // x
オプショナル要素
tupleの定義時に、オプショナル要素を定義できます:
type Point = [number, number, number?]
const p2d: Point = [1,2]
const p3d: Point = [2,3,4]
また、tupleの最後の要素は...
演算子を使うことで、任意の数のオプショナル要素を定義できます
type geoData = [number, number, ...string[]]
const location = [1234.45678, 87656.8909, 'station', 'xxx street']
readonly tuple
Pythonではtupleが読み限定なので要素の変更はできません(immutable)。これを応用するためにreadonly
キーワードをつけます:
type Point = readonly [number, number, number?]
const p = [1,2]
p[1] = 10 // x
void
void
は文字通り空白の意味で、他のタイプのデータと互換性・包括関係がありません。例外として、strictNullChecks
がfalse
の時に、null
とundefined
を付与することが可能。なので、実際の使用中に、void
タイプの変数を定義することがありません。
使い道としては、関数にリターン値がない場合、関数リターン値のタイプがvoid
となります。これはわざわざ定義しなくてもtsがタイプ推測(後で説明)できるのですが、インターフェースやタイプで関数を定義するときに書くことがよくあります。
type PrintFn = (param:string) => void
ただ、リターン値がない場合、jsではundefined
をリターンしますが、タイプ定義のところで、undefined
ではなく、void
にしなければならない:
// エラーになる
function test():undefined {
console.log('test')
}
// OK
function test():void {
console.log('test')
}
never
never
は文字通り、永遠に存在しないタイプのことです。これは2つの状況に当たります:
- 関数実行中にエラーが出ると、この関数はリターン値が存在しません(
undefined
もリターンしない)。エラーが出る場合関数の実行が中断されるため、当然リターン値もなく、never
となってしまいます - もう一つは無限ループの場合ですが、状況1と同じく、リターンまで実行することができないため、
never
となっています
根本的に言えば、到達・実行することがないのがnever
で表現されています。集合で考えると、要素のない集合(empty set)のことを指します。
// エラー
function err(msg: string): never { // OK
throw new Error(msg)
}
// 無限ループ
function loopForever(): never { // OK
while (true) {}
}
また、関数でエラーをスローすることとリターン値が同時に存在する場合、リターンタイプからnever
が省かれます:
function withErr(msg: string) { // リターンタイプがstringとなります
if (msg === 'ok') {
return 'no error!'
}
throw new Error(msg)
}
これは、集合で考えれば、リターン値のタイプがstring|never
とのことで、文字列の集合または空の集合なので、前者のみが残されます。そのため、エラーハンドリングの関数を書くときに、わざわざstring|never
とかを書かなくて良いのです。
null
とundefined
との比較
never
はnull
とundefined
と同じく、任意の他のタイプの変数に付与することが可能。ただ、never
は自分自身以外に、any
を含めて付与するいことができません:
let ne: never
let nev: never
let an: any
ne = 123 // x
ne = nev // OK
ne = an // x
ne = (() => { throw new Error('error') })() // OK
ne = (() => { while(true) {} })() // OK
never
の運用
ちょっと変なタイプ、と思われるかもしれませんが、実際に使用中はユニオンタイプの変更によるタイプ安全のために利用することが可能:
type Foo = string | number
function controlFlowAnalysisWithNever(foo: Foo) {
if (typeof foo === 'string') {
// ここは文字列
} else if (typeof foo === 'number') {
// ここは数字
} else {
// すでに可能性が尽きて、ここには到達できないため、タイプがneverとなる
const check: never = foo
}
}
すると、もし誰かがFoo
をstring|number|boolean
に変更したら、else
分岐では、boolean
をnever
に付与することができないので、ここでエラーが出ます。never
があることで、すべての可能性に対して処理が実装されていることが保証できます。
any
any
はtsの中で一番幅の広いタイプ、いわばすべてのタイプを包括する(never
は例外)型です。通常のタイプは、タイプ宣言の後に別のタイプの値を与えることができませんが、any
はなんでも良いのです。
let a: string = 'seven'
a = 7 // x
let a: any = 555
a = 'seven'
a = false
a = undefined
a = [1,2,3]
a = {name:'hoge'}
//...
「なんでもタイプ」なので、型独自のapiと属性にアクセス可能、たとえそんな属性が存在しなくてもエラーはならない:
let anyThing: any = 'hello'
console.log(anyThing.myName)
console.log(anyThing.myName.firstName)
let anyThing: any = 'Tom'
anyThing.setName('Jerry')
anyThing.setName('Jerry').sayHello()
anyThing.myName.setFirstName('Cat')
let variable
のように、初期値を付与しない、かつ変数宣言時にタイプを指定していないと、any
としてtsが認識します。ただ、初期値付与によって、tsがタイプ推測(後で説明)を行うので、タイプが限定されます。
一見「なんでもタイプ」で万能じゃん、と思われるかもしれませんが、型付けの目的、つまりタイプを制限することでコードの安全性を高めることと相反するので、できる限りany
は避けるべきです。初心者はエラー解除のためにany
を濫用し、AnyScript
を書くことがありますが、それは絶対やめましょう(AnyScript
よりJavaScript
のままで良い)。
unknown
tsバージョン3から導入されたタイプですが、ある意味で、AnyScript
問題の解決のためでもあります。any
と同じく、すべてのタイプ(never
以外)がunknown
の子タイプ、unknown
に付与することが可能。
最大の違いというのは、any
の任意タイプの値の付与は双方向・対面通行ですが、unknown
は一方通行となります。つまり、一度unknown
となると、他のタイプに戻ることができません。
let notSure: unknown = 4
let uncertain: any = notSure // OK
let notSure: any = 4
let uncertain: unknown = notSure // OK
let notSure: unknown = 4
let uncertain: number = notSure // x
そのため、unknown
が一体どんなタイプなのか、そのタイプ範囲の縮小が求められています。後で言及しますが、typeof
、断言(assertion)・キャストなどを用いることができます:
function getDogName() {
let x: unknown
return x
}
const dogName = getDogName()
// この時点でunknownなので、文字列として判断できないためエラー
const upName = dogName.toLowerCase() // x
// typeofを使うと、if分内部のタイプが文字列として制限される
if (typeof dogName === 'string') {
const upName = dogName.toLowerCase() // OK
}
// タイプキャストで文字列として断言する
const upName = (dogName as string).toLowerCase() // OK
オブジェクトラッパー(primitive object wrapper)
訳語がよくわかりませんが、簡単に言えば、number
, string
とのprimitiveタイプのオブジェクトタイプ:Number
, String
, Boolean
など。オブジェクトタイプは原始(primitive)タイプの子タイプなので、原始→オブジェクトへの付与は可能だが、逆はアウトです。
let num: number
let Num: Number
Num = num // ok
num = Num // x
なので、原始タイプのオブジェクトタイプは使用しないように覚えておけば良いでしょう。
object
, Object
, {}
)
オブジェクトタイプ(これは非常に紛らわしいところです。
小文字のobject
というのは、原始タイプ(primitive)以外のオブジェクトのタイプです。つまり、number
, string
, boolean
, symbol
, bigint
の値をobject
タイプに付与することができません。なお、strict
モードでは、null
もundefined
も付与できません(jsでtypeof null
で実行するとobject
が帰ってきますが、strict null check
の場合は除外されます)。
let lowerCaseObject: object
lowerCaseObject = 1 // ts(2322)
lowerCaseObject = 'a' // ts(2322)
lowerCaseObject = true // ts(2322)
lowerCaseObject = null // ts(2322)
lowerCaseObject = undefined // ts(2322)
lowerCaseObject = {} // ok
大文字のObject
というのは、原始タイプ+オブジェクトタイプの値を付与することが可能。ただし、object
と同じく、stric null check
の場合はnull
とundefined
が除外されます。
let upperCaseObject: Object
upperCaseObject = 1 // ok
upperCaseObject = 'a' // ok
upperCaseObject = true // ok
upperCaseObject = null // ts(2322)
upperCaseObject = undefined // ts(2322)
upperCaseObject = {} // ok
しかし、Object
とobject
は単純に親タイプ->子タイプというわけではありません:
let lowerCaseObject: object = {}
let upperCaseObject: Object = 1
type isLowerCaseObjectExtendsUpperCaseObject = object extends Object ? true : false // true
type isUpperCaseObjectExtendsLowerCaseObject = Object extends object ? true : false // true
upperCaseObject = lowerCaseObject // ok
lowerCaseObject = upperCaseObject // ok
ちょっと変な結果かもしれませんが、Object
がobject
の親タイプでもあり、子タイプでもあります。
なお、{}
はObject
と同じ位置付けとなり、お互いに入れ替えることが可能です。
結論として、{}
とObject
はobject
よりタイプの範囲が広く、原始タイプとオブジェクトタイプの値を表せますが、object
はオブジェクトタイプの値のみとなります。いずれも、strickNullChecks
モードでば、null
とundefined
は除外されます。
補足ですが、typeof []
で実行すると、object
が返ってきます。配列も、object
とObject
または{}
に付与することが可能です。
タイプ推測(inference)
tsは賢いです。その一つの特徴は、タイプを一々: type
で書かなくても、推測してくれるのです。
{
let str = 'this is string' // str:string と同じ効果
let num = 1 // num:numberと同じ、推測される
let bool = true // ブールタイプとして推測される
}
{
const str = 'this is string' // constの原始タイプは二度と付与できないため、ここは文字列ではなく、リテラルタイプとなる
const num = 1 // ここは数字ではなく、1がタイプとなります
const bool = true // ブールではなく、trueがタイプとなります
}
上記の例では、let
で宣言された変数は、変更可能のため、タイプの推測がより広めですが、const
で宣言された変数は変更できないため、タイプの範囲が制限されています。リテラルタイプ(literal)はまた後で説明します。
また、関数のリターン値を書かずに、tsが推測してくれます:
// リターン値タイプ宣言はないが、numberとして推測されます
function add(x: number, b: number) {
return x + y
}
なお、any
の節で触れていましたが、変数を宣言するだけで初期値がない場合、any
として推測されます。
冒頭にも触れていましたが、タイプ推測の原則として、可能性のあるタイプの集合・範囲として理解すれば良いでしょう。
タイプキャスト・断言(assertion)
タイプキャストは、開発者がtsより、この変数の値のタイプに確信を持っているときに、特定のタイプとして「断言」する操作です。注意してほしいのは、このタイプキャストが仮に間違ってもエラーは出ません!ここはC、Javaとかでキャスティングエラーが出ることがありません(根本的にjsです)ので、あまり濫用しないように注意が必要です。できる限り、変数宣言時にタイプを付与するのがタイプキャストより良い実践です。
ただ、実際に使う場面がしばしばあります。例えばリモートデータを取得後、リターンのデータのタイプが何なのか、tsが分かるわけがないので、ここはタイプキャストを使います。また、DOM操作の時に、document.querySelector
とかで要素を取得しますが、ここでtsがhtmlの状況がわからないので理論的にundefined
の可能性がありますが、開発者が「この要素が絶対取得できる」との確信がある時に、断言することができます。
// DOM操作
const inputEl = document.querySelector('#userInput')!
// データ取得の場合
const result = await axios.get('api/...')
const data = result.data as UserInterface
as
もう少し身近な例で言えば、例えば配列の要素から特定の要素を探す時に:
const arrayNumber: number[] = [1, 2, 3, 4]
const greaterThan2: number = arrayNumber.find(num => num > 2) // ts(2322)エラー
tsがその配列に何が入っているのか(値の付与は実行時runtimeのため)を把握できません。そのため、find
のリターン値がundefined
の可能性があると判断しました。ここで、as
キーワードで断言できます:
const greaterThan2: number = arrayNumber.find(num => num > 2) as number
as
以外に、<type>Variable
の書き方もありますが、react
のjsxと喧嘩するので、ほとんどas
が主流です。
!
の使い方
null
除外
!
をつけることで、この値はnull
とundefined
ではありません、と断言しています。
let val: null | undefined | string
val!.toString() // ok
val.toString() // ts(2531)
値の付与断言
もう一つの使い方は、初期化せず宣言だけの値に対して、!
をつけることで、実行時はちゃんと値の付与されますよ、と断言しています。
let x!: number
initialize()
console.log(2 * x) // Ok, !つけないとエラーに
function initialize() {
x = 10
}
ここの例では、initialize
関数で、x
に値を付与するため(開発者が保証するしかない)、エラーがなくなります。
リテラルタイプ(literal type)
タイプ推測の節で触れていましたが、データのタイプがstring
とかの「集合」だけではなく、1
,abc
といった、値そのものがタイプとしてあり得ます。これはリテラルタイプ(文字通りタイプ)です。リテラルタイプは変数の値の可能性・範囲を最も狭めているタイプです。
そこまで狭めて何に使う?との疑問になるかもしれませんが、基本的にタイプユニオンとかを介して、Enumと似た効果を達成できます。例えば:
type Direction = 'up' | 'down'
function move(dir: Direction) {
// ...
}
move('up') // ok
move('right') // error
interface Config {
size: 'small' | 'big'
isEnable: true | false
margin: 0 | 2 | 4
}
タイプ拡張(widening)
タイプ推測の節で例をあげましたが、let
とconst
で変数を定義することによって、tsが「集合」タイプか、リテラルタイプかを推測してくれます。
{
let str = 'this is string' // str: string
let num = 1 // num: number
let bool = true // bool: boolean
}
上記の例のように、変数のタイプの範囲・可能性を、付与された値の親タイプとして推測することが、タイプの拡張(widening)と言われます。言葉で説明すると、let
で定義された変数は、可変(mutable)ですから、後から別の値に変わる可能性があり、その可能性を表す範囲が文字列の「集合」、数字の「集合」となります。合理的な推測です。
宣言時タイプ制限
下記の例では、宣言時のタイプを明示的に示すことで、const
からlet
へのタイプ拡張が止められます。
const specifiedStr = 'this is a string' // タイプはリテラル 'this is a string'
let str = specifiedStr // ここでタイプは拡張されて、stringとなります
console.log(typeof str) // 'string'
// ただ、宣言時にリテラルタイプをつけると、letでもリテラルタイプのままで、拡張はされません
const specifiedStr: 'this is a string' = 'this is a string'
let str = specifiedStr
console.log(typeof str) // 'this is a string'
const
の限界:オブジェクトと配列
上記の例で、const
を使うことで、タイプ拡張を制限すること(可能性の範囲を狭めること)が可能ですが、オブジェクトと配列の場合は思う通りに行かないかもしれません。
const obj = {
x: 1,
}
obj.x = 6 // ok
obj.x = '6' // x
obj.y = 8 // x
obj.name = 'hoge' // x
こちらの例で見ると、obj
にconst
を使っていますが、タイプは{x:number}
と推測されています。オブジェクトの場合、明示されない限り、プロパティはlet
キーワードと同じようにタイプ拡張が行われます。つまり、プロパティx
のタイプは数字1
ではなく、親タイプのnumber
となります。obj.x=...
のように値の変更が可能なので、合理的な推測です。ただ、同時に新しいプロパティの追加が不可となります。つまり、const
がオブジェクトの属性の数を制限しますが、属性のタイプ拡張を制限できません。
タイプを明示するともちろん、こちらで拡張かどうかを決めているわけなので、よりはっきりとなります:
const obj: {x: 1|2|3, name: string} = {x:1, name:''}
もしくは、const
タイプキャスト・断言を使うことで、プロパティのlet
宣言をconst
宣言に変えることができます:
const obj = {
x: 1 as const, // xのタイプは1
y: 2 // yのタイプはnumber
}
// objタイプは {readonly x:1 readonly y: 2}
const obj = {
x: 1,
y: 2
} as const
つまり、as const
を使うことによって、tsに「タイプの範囲・可能性を最も狭めるようにする」と伝えているので、タイプの拡張がなくなります。
なお、配列にも使うことが可能です:
// number[]
const arr1 = [1, 2, 3]
// readonly [1, 2, 3]
const arr2 = [1, 2, 3] as const
null
, undefined
からの拡張
変数を宣言するときに値を付与しない限り、any
として推測されますが、仮にnull
かundefined
として初期化すると(undefined
は付与しない場合と同様だが)、strict null check
がfalse
の時に、any
まで拡張されます。
// 注意、strictNullChecks:false
let x = null // タイプは any
let y = undefined // タイプは any
const z = null // タイプは null
let anyFun = (param = null) => param // 引数タイプは null
let z2 = z // タイプは null
let x2 = x // タイプは null
let y2 = y // タイプは undefined
上記の例のように、null
で初期化して、他の変数に付与するケースがあまりないでしょうが、注意点として、x2
とy2
は拡張されていなく、null
とundefined
のままです。
タイプ縮小(narrowing)
タイプの拡張について色々と見ましたが、ここでは、縮小(narrowing)について見てみます。tsでは、特定の操作によって、より幅広いタイプを、狭い範囲に縮小・狭めることができます。
例えば、よく使われるタイプガード(type guard):
function checkType(param:unknown) {
if (typeof param === 'string') {
console.log('type is string')
return param
} else if (typeof param === 'number') {
console.log('type is number')
return param
}
// ...
throw new Error('invalid value type')
}
tsが条件判断を追跡し、ブロック内のparam
のタイプを推測できます。ブロック内では、param
がunknown
から、string
へ縮小されました。条件判断でタイプを縮小することがtsにおいて非常に有効な手段です。
もう一つよく使う例として、配列からnull
とundefined
を除外することです。単純にarr.filter(el => el !== null && el!== undefined)
でかけても、arr
のタイプ定義からnull
とundefined
を除外できません。この場合はタイプガードを利用します:
const notNullable = <T>(value: T | null | undefined): value is T => value !== null && value !== undefined;
arr.filter(notNullable)
注意点
ただ、一部jsの落とし穴もあります。例えば:
const el = document.getElementById("userInput") // Type is HTMLElement | null
if (typeof el === "object") {
el // Type is HTMLElement | null
}
こちらの例で、null
を除外するために、typeof
ガードを使っていますが、jsではtypeof null
がobject
なので、実際にタイプ縮小ができていません。
また、falsy
な値、つまり!!
をつけることでfalse
ブールとして判断される値も同じ問題があります。
function foo(x?: number | string | null) {
if (!x) {
x // Type is string | number | null | undefined
}
}
x
は全く狭めることができていません。というのは、''
, 0
もjsでfalse
としてブールに変換(coersion)されるからです。
タイプのタグをつける
もう一つ縮小チェックに役立つ方法として、明示的にtype
のタグをつけることです:
interface UploadEvent {
type: "upload"
filename: string
contents: string
}
interface DownloadEvent {
type: "download"
filename: string
}
type AppEvent = UploadEvent | DownloadEvent
function handleEvent(e: AppEvent) {
switch (e.type) {
case "download":
e // Type is DownloadEvent
break
case "upload":
e // Type is UploadEvent
break
}
}
こちらがタグ付けユニオン(tagged union)と呼ばれるパターンです。reduxとかを使うと、reducerのswitch (action.type)
がまさにこれにあたります。
ユニオンとインターセクション
ユニオンタイプを使う
何度もユニオンが出ていて、今更かもしれませんが、ユニオンタイプ(和集合)について簡単に説明します。要するに|
を使って、OR
のロジック関係を表示することです。
let param: string | number | boolean
function sayHi(name?:stirng) { // オプショナル引数は、name: string|undefinedのユニオンタイプと同じ
//
}
ユニオンタイプを使うことで、タイプの縮小と拡張を行うことがよくあります。前にも触れましたが、ある意味でEnumの効果と近いです(Enumについていまた説明します)。
type
インターフェースやインターフェースやtype
で定義する場合、|
を使うとどうなるでしょうか:
type A = {x: string}
type B = {x: number}
type C = A | B
let test: C = {x:true} // Type 'true' is not assignable to type 'string | number'.(2322)
ここでx
という共通属性に対して、同じ合併操作が行われるので、C
のx
タイプがstring|number
となりました。
もし、原始タイプではなく、オブジェクトタイプとなると:
type A = {x: {a:string}}
type B = {x: {b:number}}
type C = A | B
let test1: C = {x:{a:'1', b:5}} // ok
let test2: C = {x:{a:'1'}} // ok
let test3: C = {x:{b:5}} // ok
この場合C
のx
のタイプが、{a: string} | {b:number}
となります。いずれかが入っているか、両方入っているか、どちらでも問題ありません。
インターセクション(共通タイプ)
|
とOR
と反対に、&
とAND
の共通タイプもあります。複数のタイプの「共通部分」を取る意味です。
ただ、共通部分なので、お互いに包括関係・共通する範囲が存在しなければ、意味がありません:
type Useless = string & number // neverとなります
同時に数字であり、文字列でもある値は存在しないため、上記はnever
となります。
そのため、使う場面は他にあります:
type IntersectionType = { id: number name: string } & { age: number }
const mixed: IntersectionType = {
id: 1,
name: 'name',
age: 18
}
こちらの例で、インターフェースの合併に、&
を用いています。インターフェースのところでまた触れますが、&
を使って合併することで、継承と同じ効果に達成することが可能です。
問題点
ただ、合併の際に、同じ名前の属性があるとすると、どんなことが起きるでしょうか。
type IntersectionTypeConfict = { id: number name: string }
& { age: number name: number }
const mixedConflict: IntersectionTypeConfict = {
id: 1,
name: 2, // x
age: 2
}
上記の例で、name
がnever
タイプに付与できないとのエラーが出てきます。つまり、同じname
名前の属性に合併が行われますが、共通部分が存在しないためnever
となります。name
が必須属性ですが、付与できる値がないため、最初のUseless
と同じく、使い道のないタイプが作られました。
また、共通部分を取るため、合併後のタイプは最も小さい範囲の方に狭まれます:
type IntersectionTypeConfict = { id: number name: 2 }
& { age: number name: number }
let mixedConflict: IntersectionTypeConfict = {
id: 1,
name: 2, // ok
age: 2
}
mixedConflict = {
id: 1,
name: 22, // x , nameのタイプがリレラルタイプ2
age: 2
}
オブジェクトタイプの属性合併
上記の例では、原始タイプの合併について言及しましたが、オブジェクトタイプの場合はどうなるでしょうか。
interface A {
x:{d:true},
}
interface B {
x:{e:string},
}
interface C {
x:{f:number},
}
type ABC = A & B & C
let abc:ABC = {
x:{
d:true,
e:'',
f:666
}
}
この例の通り、エラーは出ていなく、オブジェクトの属性は合併されています。つまり、同じ名前の属性であっても、原始タイプではなく、オブジェクトであれば、合併することが可能です。
ただ、ユニオンの節での例と比べて、根本的な違いがあります。
type A = {x: {a:string}}
type B = {x: {b:number}}
type C = A | B
type D = A & B
let test1: C = {x:{a:'1', b:5}} // ok
let test2: C = {x:{a:'1'}} // ok
let test3: C = {x:{b:5}} // ok
let test4: D = {x:{a:'1', b:5}} // ok
let test5: D = {x:{a:'1'}} // x
let test6: D = {x:{b:5}} // x
つまり、&
で合併されたタイプは、すべて必須属性(AND
)となっており、|
で合併されたタイプは、いずれかまたはすべて(OR
)となっています。
結論として、
- 原始タイプの属性に
&
を使う場合、交差・共通部分を取ることになります。 - 原始タイプの属性に
|
を使う場合、合併集合を取ることになります。 - インターフェース(タイプエリアスも含む)のオブジェクトタイプ属性に
&
を使う場合、共通部分を取るのではなく(集合のA ∩ B
)、合併後の集合を取ることです(集合のA ∪ B
)。 -
&
を使う場合、オブジェクトタイプの属性のタイプは、合併のすべてが必須となります(AND
) -
|
を使う場合、オブジェクトタイプの属性のタイプは、合併のいずれかまたはすべてが許されます(OR
)
タイプエイリアス(type alias)とインターフェース
タイプエリアス
すでにあっちこっちに出ていますが、type
キーワードを使って、一つのタイプに名前をつけることです。注意が必要なのは、新しいタイプを作ったわけではなく、ただ名前のエリアス(別名)を作っただけです。ユニオンやインターセクションなどと併用することが多いです。
インターフェース
最初に言っておく必要がありますが、tsのインターフェースは伝統的なOOP言語のインターフェースの概念と違います。
インターフェースを聞くと、関数などを抽象化して、クラスが具体的に実現(implement)することが浮かんで来るかもしれません。tsでは、OOPでの使い方だけではなく(ただ一部は制限あり)、「オブジェクトの形状・構造(shape)」を記述することが可能です。つまり、このオブジェクトにどのような属性があり、メソッドがあり、それぞれどんなタイプか、それを定義することができます。
インターフェースで定義した「形状」があるかぎり、値を与える時に、この「形状」に合わせなければなりません:
interface Person {
name: string
age: number
}
let tom: Person = {
name: 'Tom',
age: 25
}
// 属性は少ないのはx
let tom: Person = {
name: 'Tom'
}
// 属性は多いのもx
let tom: Person = {
name: 'Tom',
age: 25,
gender: 'male'
}
オプショナル属性と読み属性
interface Person {
readonly name: string
age?: number
}
let a: number[] = [1, 2, 3, 4]
let ro: ReadonlyArray<number> = a
ro[0] = 12 // error!
ro.push(5) // error!
ro.length = 100 // error!
a = ro // error!
任意属性(arbitary property)
関数の必須引数、オプショナル引数、任意数の引数と同じように、任意属性を定義することができます:
interface Person {
name: string
age?: number
[propName: string]: unknown // [xxx: type] : typeで定義、propNameは他の名前でも良い
}
let tom: Person = {
name: 'Tom',
gender: 'male'
}
ただ、一つ注意しないといけないのが、任意属性が存在する限り、必須属性とオプショナル属性がすべて任意属性の子タイプでなければなりません:
interface Person {
name: string
age?: number
[propName: string]: string
}
// Property 'age' of type 'number | undefined' is not assignable to 'string' index type 'string'.(2411)
let tom: Person = {
name: 'Tom',
age: 25,
gender: 'male'
}
// Type '{ name: string age: number gender: string }' is not assignable to type 'Person'.
// Property 'age' is incompatible with index signature.
// Type 'number' is not assignable to type 'string'.(2322)
ここで、age
のタイプはインターフェースの任意属性に制限されて、number
として定義できません。もちろん、値を与えることもできません。
なお、関数の任意数引数が一つしか存在できないと同じように、インターフェースにも一つしか定義できません。必ず一番最後におく必要がありませんが、慣習的に任意属性は最後におきます。
アヒルタイプ(duck typed)
インターフェースはオブジェクトの形状・構造を定義しており、属性が一致しないといけない、と最初に言いましたが、下記の例では、それと相反するのでは、とよく誤解されます。
interface Printable {
print():void
}
function printLabel(labeledObj: Printable) {
console.log(labeledObj.print())
}
const obj = {
label: 'name',
size: 'L',
print() {
console.log('print an object')
}
}
printLabel(obj) // ok
この例のように、obj
には、Printable
インターフェースで定義されたprint
関数以外に、label
とsize
を持っていますが、エラーとなりません。OOPのインターフェースと同じように、該当関数を実現すれば良いのです。つまり、他に属性があるかどうかは関心を持ちません。
これは、アメリカの諺「アヒルのように叫び、アヒルのように歩き、ならそれはアヒル」に因んだ、アヒルタイプもしくは構造的タイピング(structural typing)のやり方です。インターフェースで決めた属性が含まれていれば=構造的に互換性があれば、そのインターフェースを実現していると見なして(アヒルだと認定)、別に文句は言わないと、とのことです。なお、この構造的タイピングはtsのタイプ判定システムの仕様で、インターフェースかタイプエリアスか、オブジェクトタイプ属性か原始タイプ属性かと関係ありません。
interface Vector2D {
x: number
y: number
}
interface NamedVector2D {
name: string
x: number
y: number
}
interface Location {
x: string
y: string
}
function calculateLength(v: Vector2D) {
return Math.sqrt(v.x * v.x + v.y * v.y)
}
const v: NamedVector = { x: 3, y: 4, name: 'Vector' }
const v2: NamedVector = { x: 4, y: 5 } // x
const l: Location = {x: '...', y: '...'}
calculateLength(v) // ok
calculateLength(l) // x
こちらの例のように、引数v
がVector2D
と決めていますが、構造的に、Vector2D
とNamedVector2D
に互換性があるので、問題ありませんが、Location
の方はアウトです。また、v2
を定義する際にすでにタイプを宣言しているため、属性が一致しないとエラーが出ます。これは本質的に、値の構造と、宣言されたタイプの構造が一致しないからです。そのため、アヒルタイプの認定と、宣言タイプ一致性チェックには矛盾していなく、むしろ基準は共通しています。
タイプ一致性チェックを無効にする方法について
上記のアヒルタイプ認定以外に、一致性チェックを無効にすることが他にも存在します。一つは前に説明しました、タイプキャスト・断言です。
interface Props {
name: string
age: number
money?: number
}
let p: Props = {
name: "hoge",
age: 25,
money: -100000,
gender: 'male'
} as Props // OK
繰り返しになりますが、C, Javaとかでこれでタイプキャスティングエラーになりますが、tsでは大丈夫です。そのため、as
を使う時には慎重に考えておくのが必要です。
もう一つのやり方が、インデックスシグネチャ(indexed signature)です。簡単に言えば任意数の属性のことです。
interface Props {
name: string
age: number
money?: number
[key: string]: unknown
}
let p: Props = {
name: "hoge",
age: 25,
money: -100000,
gender: 'male'
} // OK
いずれもすでに紹介しているので、ここは省略します。他の角度から、これらの操作は何を意味しているかを考えることが目的です。
タイプエイリアスとインターフェースの違い
よくある質問で、探せばいっぱい説明の文章があります。結論を先に出しますが、ほとんどの場合機能的に同じく、公式ではインターフェースの方が薦められています。
オブジェクトと関数
両方関数とオブジェクト(関数も一種のオブジェクト)を定義できます。違いといえばシンタクスレベルです。
interface Point {
x: number
y: number
}
interface SetPoint {
(x: number, y: number): void
}
type Point = {
x: number
y: number
}
type SetPoint = (x: number, y: number) => void
オブジェクト以外のタイプ
ただ、インターフェースはオブジェクト、関数以外の原始タイプ、ユニオンなどを定義できません。インターフェース自体の構造がオブジェクトとして考えても良いでしょう。タイプエリアスにはこのような制限がもちろんありません。
// primitive
type Name = string
// object
type PartialPointX = { x: number }
type PartialPointY = { y: number }
// union
type PartialPoint = PartialPointX | PartialPointY
// tuple
type Data = [number, string]
// dom
let div = document.createElement('div')
type B = typeof div
重複定義
インターフェースは同じ名前で何回も定義可能で、最終的に&
操作のように合併されます。タイプエリアスは一度しか定義できません。
interface Point { x: number }
interface Point { y: number }
const point: Point = { x: 1, y: 2 }
タイプ拡張(extends)
両方定義を拡張することが可能です。また、タイプエリアスがインターフェースから拡張、インターフェースがタイプエリアスから拡張することが可能です。
ここで拡張と言いましたが、widening
の意味ではなく、extends
キーワードのことです。jsでは継承として使われます。タイプエリアスにはextends
が使えないので、&
演算子を使うことで同じ効果になります。
interface PointX {
x: number
}
interface Point extends PointX {
y: number
}
type PointX = {
x: number
}
type Point = PointX & {
y: number
}
interface PointX {
x: number
}
type Point = PointX & {
y: number
}
type PointX = {
x: number
}
interface Point extends PointX {
y: number
}
ジェネリック(generic)
ジェネリックとは
例えば、任意のデータタイプを引数として入れて、その引数をそのままリターンする関数を実装すると:
const identity = (arg:any) => arg
type idBoolean = (arg: boolean) => boolean
type idNumber = (arg: number) => number
type idString = (arg: string) => string
// ...
jsにはさほどタイプが存在しないからなんとかなるかもしれませんが、もし将来的に新しいデータタイプが実装されると、タイプ定義に追加しなければなりません。
ここは、タイプに関心を持たないわけではなく、要するに、どんなタイプでも良いだが、決まっていればそのタイプのままにしてほしい、をtsに伝えたいことです。
ジェネリックはそのための存在です:
function identity<T>(arg:T): T {
return arg
}
言葉通り、共通的・汎用的、との意味です。シンタクスは、<>
の中で、その抽象化されたタイプの名前を入れます。慣習的に、T
=Type, K
=Key, V
=Value, E
=Elementなどの書き方があります。
ジェネリックを定義した関数を呼び出す時に、identity<number>(1)
でも良いですが、通常<>
内のタイプはtsに推測してもらうので省略します。そのため、一般のjs関数の書き方と違いがありません: identity(1)
。
ジェネリックタイプを制約
例えば、length
という属性のあるタイプに制約したい、のであれば、どうすれば良いでしょうか。
interface HasLength {
length: number
}
function checkLength<T extends HasLength>(arg:T) {
console.log(arg.length)
}
extends
キーワードを使い、タイプに制約をかけることができます。直感的に、extends
が「拡張」との意味なのに、なぜ「制約」をかけているだろう、と違和感があるかもしれません。アヒルタイプの節で説明しましたが、ここのT
はlength
以外にも属性を持つことが可能なので、HasLength
の{length: number}
より幅が広く、「拡張」されている意味合いとなります。any
からHasLength
の方向から見れば、確かに「制約」に見えますが、extends
の出発点と方向が違います。
よく用いるキーワード
tsにはよく使われるタイプを編集するツール・関数があります。例えば、Partial
, Omit
, Record
, ReturnType
など。これらの実現を理解するために、いくつかのキーワードとその演算をまず理解する必要があります。
typeof
jsにも存在しますが、対象のデータタイプを文字列としてリターンします。tsで使う場合、変数のタイプを抽出し、他の変数のタイプ宣言に使うことが可能です:
interface Person {
name: string
age: number
}
const a: Person = { name: "hoge", age: 30 }
type Human = typeof a
const b: Human = { name: 'xxx', age: 50}
function toArray(x: number): Array<number> {
return [x]
}
type Func = typeof toArray // -> (x: number) => number[]
keyof
keyof
はあるデータタイプのすべての属性の名前とタイプを抽出することができます。抽出されたタイプがユニオンタイプとなります。
interface Person {
name: string
age: number
}
type K1 = keyof Person // "name" | "age"
type K2 = keyof Person[] // "length" | "toString" | "pop" | "push" | "concat" | "join"
type K3 = keyof { [x: string]: Person } // string | number
K3
が若干意味不明かもしれませんが、ここはjsのインデックスシグネチャのタイプと関わっています。jsで配列またはオブジェクトのキーを作るときに、数字または文字列が使えますが、数字を使っても、実行時は文字列に変換されることになります。obj[1]
とobj['1']
の効果が全く同じです。
tsではこれを対応するので、keyof [x:string]
の時に文字列だけではなく、number
もあり得るよとのことです。
また、tsオンリーですが、数字のみのインデックスを使うこともできます:
interface StringArray {
// keyof StringArray => string | number
[index: string]: string
}
interface StringArray1 {
// keyof StringArray1 => number
[index: number]: string
}
keyof
は非常に有用です。一つの例として、オブジェクトとそのオブジェクトのキーを引数とする場合です:
function getProp<T extends object, K extends keyof T>(obj: T, key: K) {
return obj[key]
}
上記のように、keyof
を使うことで、オブジェクトの属性まで範囲を縮小できます。keyof
で取得できたのはユニオンタイプなので、いずれかを満足すれば良いのです。もっと具体的な例で言えば:
type Todo = {
id: number
text: string
done: boolean
}
const todo: Todo = {
id: 1,
text: "Learn TypeScript",
done: false
}
function prop<T extends object, K extends keyof T>(obj: T, key: K) {
return obj[key]
}
const id = prop(todo, "id") // const id: number
const text = prop(todo, "text") // const text: string
const done = prop(todo, "done") // const done: boolean
const date = prop(todo, "date") // Argument of type '"date"' is not assignable to parameter of type 'keyof Todo'.(2345)
in
in
はイタレーション(iteration)の演算子です。for...in
ループがありますが、それと近い意味合いです。
type Keys = "a" | "b" | "c"
type Obj = {
[p in Keys]: any
} // -> { a: any, b: any, c: any }
infer
条件判断の時にジェネリックタイプを推測することに使います。
type PromiseReturnType<T> = T extends Promise<infer Return> ? Return : T
type t = PromiseReturnType<Promise<string>> // string
type ArrayType<T> = T extends (infer Item)[] ? Item : T
type t = ArrayType<[string, number]> // string | number
tsには、関数のリターン値のタイプを抽出する操作(RetrunType
)がありますが、infer
が使用されています:
type ReturnType<T> = T extends (
...args: any[]
) => infer R ? R : any
もっと詳しい説明はこちら
extends
extends
によるタイプ制約についてすでに説明されているのでここは省略。
タイプマッピング
既存のタイプより新しいタイプを作り出す・マッピングすることです。
例えば、CRUD操作の時に、createの場合はすべて必須属性ですが、updateの時は全部オプショナルに使いたい。しかしもう一回同じ属性リストから別のインターフェースを定義して?
をつけるのはDRY
原則に違反です。こういう時にマッピング操作が役立ちます。
type User = {
id: number
name: string
gender: string
// ...30個くらい別の属性があるとする
}
type OptionalInterface<T> = {
[p in keyof T]+?:T[p] // Tにあるすべての属性の後ろにに?をつける、値の変更はなし
}
type OptionalUser = OptionalInterface<User>
// こちらと同等
type OptionalUser = {
id?: number
name?: string
gender?: string
}
tsからすでにPartial
で実現できますが、原理の部分はマッピング操作です。
タイプ条件操作(conditional typing)
tsには、ジェネリックタイプが動的に変わるので、条件判断をつけることで、動的にタイプを定義することも可能です。基本的に、jsのternary演算子と同じ書き方です(condition ? true result : false result
)
type StringFromType<T> = T extends string ? 'string' : never
type lorem = StringFromType<'lorem ipsum'> // 'string'
type ten = StringFromType<10> // never
上記の例はあまり実用性がないかもしれませんが、ユニオンタイプと一緒に使うことが多いです。
type NullableString = string | null | undefined
type NonNullable<T> = T extends null | undefined ? never : T // nullとundefined以外のタイプ、strictモードで{}、objectと相当
type CondUnionType = NonNullable<NullableString> // `string`
例えば、tsで提供されている除外と抽出の機能ですが、条件判断の運用となります。never
が空の集合として考えられるので、never
がリターン値の場合は何もないと同然です。運用の具体例はまた後にあげます
type Extract<T, U> = T extends U ? T : never // T が U の子タイプであれば抽出
type Exclude<T, U> = T extends U ? never : T // T が U の子タイプであれば何もしない、でなければTをリターン
ts内蔵ジェネリックツール紹介
Partial
タイプT
の属性をすべてオプショナルに変換する操作。繰り返しになりますが、原理はこちら:
type Partial<T> = {
[P in keyof T]?: T[P]
}
ただ、Parial
はshallowであり、2つ以上の階層のあるオブジェクトの場合、nested属性には反映されません。後で紹介するReadOnlyも同じです。
自分でDeepPartial
を実現すると、再帰をかければ良いのです:
type DeepPartial<T> = {
[U in keyof T]?: T[U] extends object
? DeepPartial<T[U]>
: T[U]
}
Required
Partial
と相反する機能で、こちらはオプショナル属性を必須属性に変えます。要は?
を除くことです。
type Required<T> = {
[P in keyof T]-?: T[P]
}
Readonly
上記の2つと結構似ている構造ですが、要はreadonly
をすべての属性の前につけることです。
type Readonly<T> = {
readonly [P in keyof T]: T[P]
}
Pick
Pick
は言葉通り、とあるタイプから一部の属性を選び出すことです。
type Pick<T, K extends keyof T> = {
[P in K]: T[P]
}
ここのK
は通常ユニオンタイプで表ます:
interface Todo {
title: string
description: string
completed: boolean
}
type TodoPreview = Pick<Todo, "title" | "completed">
const todo: TodoPreview = {
title: "learn ts",
completed: false,
}
Record
これはちょっと変な操作ですが、例を見た方がわかりやすいです。
interface PageInfo {
title: string
}
type Page = "home" | "about" | "contact"
const x: Record<Page, PageInfo> = {
home: { title: "home page" },
about: { title: "about page" },
contact: { title: "contact page" },
}
定義は以下のなっています。要は、K
の属性は変わらず、K
の属性の値をすべてタイプT
に変えることです
type Record<K extends keyof any, T> = {
[P in K]: T
}
ReturnType
言葉通り、関数のリターン値のタイプを取得することです。infer
の節で触れていましたが、定義は以下となります。
type ReturnType<T extends (...args: any[]) => any> = T extends (
...args: any[]
) => infer R
? R
: any
Exclude
とExtract
先ほど触れましたが、定義は以下となります
type Extract<T, U> = T extends U ? T : never // T が U の子タイプであれば抽出
type Exclude<T, U> = T extends U ? never : T // T が U の子タイプであれば何もしない、でなければTをリターン
例で見てみると:
type T0 = Exclude<"a" | "b" | "c", "a"> // "b" | "c"
type T1 = Exclude<"a" | "b" | "c", "a" | "b"> // "c"
type T2 = Exclude<string | number | (() => void), Function> // string | number
type T3 = Extract<"a" | "b" | "c", "a" | "f"> // "a"
type T4 = Extract<string | number | (() => void), Function> // () =>void
ユニオンタイプで除外と抽出の操作を行うことが多いですが、その場合はループが行われます。例えば、T3
のタイプとは、a, b, cの順で、'a'|'f'
の集合に属しているかどうかを判断します。その結果、'a'
だけ満足するため、'a'
が抽出されます。
Omit
言葉通り、省略との意味です。定義は、先ほどのPick
とExclude
を併用しています:
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>
使い道も結構多いので、覚えておくと良いでしょう:
interface Todo {
title: string
description: string
completed: boolean
}
type TodoPreview = Omit<Todo, "description">
const todo: TodoPreview = {
title: "Clean room",
completed: false,
}
NonNullable
infer
の節で触れましたが、null
とundefined
以外のタイプを指すことです
type NonNullable<T> = T extendsnull | undefined ? never : T
type T0 = NonNullable<string | number | undefined> // string | number
type T1 = NonNullable<string[] | null | undefined> // string[]
Parameters
言葉通り、引数を抽出する操作です。抽出された引数を、tuple
として定義されます。
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any? P : never
例えば:
type A = Parameters<() =>void> // []
type B = Parameters<typeof Array.isArray> // [any]
type C = Parameters<typeof parseInt> // [string, (number | undefined)?]
type D = Parameters<typeof Math.max> // number[]
any
タイプを扱う
通常のタイプシステムはスタティックかダイナミックかに分けられますが、tsはこのボーダーラインを曖昧にしている。というのは、tsにおけるタイプシステムが、オプショナルかつ漸進的(gradual)だからです。jsからtsへマイグレートするときも、いきなり全てを書き直すより、少しずつ進めるのが可能です。この境界線におくキーとなるのは、any
タイプとなります。any
は魔法のようにタイプチェックを無力化させることができ、どんなタイプが正しいかがわからない時にとりあえずany
で、というふうに濫用に至る恐れもあります。ここではany
の使い方と注意点について紹介します。
この節の内容と例は、こちらの著書の第5章Working with anyを参照しています。興味のある方は原作をぜひ読んでみてください。
any
タイプの影響範囲に注意
例えば次の例を考えてみよう。processX
関数は、X
タイプの引数が必要ですが、fnReturningY
では、タイプY
をリターンしています。すると、fn
でprocessX
を呼び出す時にエラーになります。
function processX(x:X) {
//...
}
function fn() {
const x = fnReturningY()
processX(x) // Argument of type 'Y' is not assignable to parameter of type 'X'
}
もし開発者として、ここのxは大丈夫だ、とわかるとしたら、解決法は:
// 解決法1
function fn() {
const x: any = fnReturningY()
processX(x)
}
// 解決法2
function fn() {
const x = fnReturningY()
processX(x as any)
}
// 解決法3
function fn() {
const x = fnReturningY()
// @ts-ignore
processX(x)
}
とありますが、2の方が断然におすすめです。というのは、1の方法と比べると、xをY
からany
へタイプキャストする効果は、processX
関数のスコープ内のみ効きます。仮にprocessX
以降にまたx
を扱うコードがあれば、any
ではなく、Y
のままで扱われます。
さらに上記の例でいうと、仮にfn
ではx
をリターンしているのであれば、解決法1にすると、リターン値もany
となります。リターン値がany
というのは「伝染」しやすいので、コードベースに与える影響が大きいです。これが心得の一つ、any
をキャストするときに、影響範囲の最も少ないように、そのスコープを考えることです。また、こういうこともがあるから、関数のリターンタイプを明示的に示すのがおすすめです。
// 解決法1
function fn() {
const x: any = fnReturningY()
processX(x)
return x
}
方法3について、一つの選択肢として考えられます。これはタイプ変更に影響はしませんが、その下の行のエラーだけを静かにできます。ただ、通常tsのタイプチェックには、それなりのわけがあるから、文句を言っていますので、使う時に本当に大丈夫かと考え直した方が良いでしょう。また、一行だけなので、同じ@ts-ignore
を何回も繰り返す時は、コード自体を見直すべきかもしれません。
関数だけではなく、オブジェクト変数でも、any
を使う時にスコープを考慮する必要があります。例えば:
const config: Config = {
a: 1,
b: 2,
c: {
k: v
// property ... missiong in type '...' but required in type '...'
}
}
とのようなエラーが出る時に、方法1より方法2の方が良いです。
// 方法1
const config: Config = {
a: 1,
b: 2,
c: {
k: v
}
} as any
// 方法2
const config: Config = {
a: 1,
b: 2,
c: {
k: v as any
}
}
まとめると、
-
any
へキャストする時に影響範囲を最小限に - リターンタイプを
any
にするとコードベースに伝染するので避けるべし - タイプチェックを一時的無効にするためなら、
@ts-ignore
をany
以外の選択肢として考える
any
を使うより、制限をかけたany
を使う
ただのなんのことかよくわからないかもしれませんが、要はany
というより、any[]
とか、{[key:string]: any}
とかのふうに、any
の適応範囲に制限をかけることです。
例えば:
const fn1 = (...args: any) => args.length // any タイプをリターン
const fn2 = (...args: any[]) => args.length // number タイプをリターン
また、no index signature
とのエラーも、よくobject/{}
のタイプにみられます:
const fn = hasTenLetterKey(o: object) {
for (const key in o) {
if (key.length === 10) {
console.log(key, o[key])
// Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.
// No index signature with a parameter of type 'string' was found on type '{}'
return true
}
}
return false
}
非常に紛らわしいエラーですが、よく使う解決法として、例えばDict
のタイプを作るとか:
type Dict = {[key:string]: any}
function hasTenLetterKey(o: Dict) {
for (const key in o) {
if (key.length === 10) {
console.log(key, o[key])
return true
}
}
return false
}
まとめると、ただのany
というよりも、any[]
、{[key:string]: any}
といった制限のあるany
が良いでしょう。
タイプ安全でない断言を関数の中に隠す
またの意味不明なタイトルですが、下記の例をみてみよう:
function shallowObjEqual<T extends object>(a: T, b: T): boolean {
for (const [k, aVal] of Object.entries(a)) {
if (!(k in b) || aVal !== b[k]) {
// Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.
// No index signature with a parameter of type 'string' was found on type '{}'
return false
}
}
return Object.keys(a).length === Object.keys(b).length
}
先のほどのDict
タイプを作る例と同じ問題ですが、object
の子タイプである限り、上記のエラーメッセージが出てきます。ただ、ここはジェネリックを使っているので、タイプをDict
に変換したりするのができません。むしろ、T
を保持してほしいから、ジェネリックタイプを使っています。他に、T
を処理して、リターンするのもT
のままが良い、との場面も同じ問題に出会います。
このエラーメッセージをタイプ安全の方法で消すには非常に大変です。という時に、any
が救いになります:
function shallowObjEqual<T extends object>(a: T, b: T): boolean {
for (const [k, aVal] of Object.entries(a)) {
if (!(k in b) || aVal !== (b as any)[k]) {
// Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.
// No index signature with a parameter of type 'string' was found on type '{}'
return false
}
}
return Object.keys(a).length === Object.keys(b).length
}
ここでany
をキャストしても、tsにとってはタイプ安全ではありませんが、関数全体の機能を壊す心配がありません。また、このキャストは関数の中に隠されているので、影響範囲も少なくてすみます。
any
タイプの進化(evolve)を理解する
通常タイプが変数宣言時に決まった以上、変更することがありませんが、any
は例外です。
any[]
配列の例えば、次の例を見てみると:
function range(start: number, end: number) {
const res = [] // any[]
for (let i = start; i < end; i++) {
res.push(i) // any[]
}
return res // number[]
}
res
宣言時にタイプを明示していないため、tsにはany[]
として推測されました。for
ループ中にも、any[]
ですが、ループが終わってリターンするときに、なぜかnumber[]
となりました。
これは、前述のタイプ縮小とは別物です。例えば:
const res = [] // any[]
res.push('a')
res // string[]
res.push(1)
res // (string | number)[]
縮小だけでなく、数字を入れると、なんと拡大されました。
条件分岐のケース
また、条件分岐付きのところも、分岐毎に異なります:
let val // any
if (Math.random() < 0.5) {
val = /hello/
val // RegExp
} else {
val = 12
val // number
}
val // number | RegExp
null
とtry/catch
のケース
もう一つの例は、null
で初期値を与えて、try/catch
と併用するケースです:
let val = null
try {
// ...
val = 12
val // number
} catch (e) {
console.warn('error!')
}
val // number | null
if
ブロックと近いかもしれませんが、合理的な推測です。
any
明示的なただ、上記の2つの例は、implicit any
の場合のみ見られますが、明示的にany
をタイプにつけると、このタイプ進化が消えます:
let val: any // any
if (Math.random() < 0.5) {
val = /hello/
val // any
} else {
val = 12
val // any
}
val // any
let val: any
try {
// ...
val = 12
val // any
} catch (e) {
console.warn('error!')
}
val // any
implicit any
エラーの理由
tsconfigには、noImplicitAny
との項目があり、デフォルトではtrue
になっています。要するに、明示的にany
タイプをつけるのではなく、タイプを示さずに、any
だとtsに推測される場合はエラーを出す、ということです。
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
先ほどの例では、res
がany[]
として宣言していなく、推測されていますので、このルールに当たるはずですが、エラーがありません。
function range(start: number, end: number) {
const res = [] // any[]
for (let i = start; i < end; i++) {
res.push(i) // any[]
}
return res // number[]
}
この例を少し変えてみると:
function range(start: number, end: number) {
const res = [] // any[]
if (start === end) {
return res // Variable 'res' implicitly has an 'any[]' type.
}
for (let i = start; i < end; i++) {
res.push(i) // any[]
}
return res // number[]
}
???というのは正直な感想ですが、どうやら、暗黙のanyの状態で読まれると、エラーとなることです。つまり、res.push
操作は書きなので、このルールに違反していません。最終的にnumber[]
に変わりますので、リターン(読み)されても問題ないです。if(start === end)
ブロックでは、暗黙のanyのままでリターンされているので、このルールの違反となりました。そのため、暗黙のanyとして宣言されても、処理の中でタイプ進化することによって、このnoImplicitAny
ルールを満たすことが可能です。
まとめとして
暗黙のany
のタイプ進化行為は若干紛らわしいところで、他のタイプの拡大・縮小の一方通行とは違い、処理によって「自由自在」に変化できるのです。暗黙のany
を扱う時には、このタイプの進化・変化を理解する必要があります。また、明示的にany
を宣言する時の違いと、noImplicitAny
チェックのタイミングについてもわかった方が良いでしょう。
any
の代わりにunknown
を使う
unknown
を紹介するときに述べていますが、ある意味で、any
の濫用防止のためにunknown
が導入されていました。もちろん、any
をすべて代替することができませんが、値のタイプが分からない場面に使うことができます。
any
, unknown
, never
この三者には、違うタイプの値を割り当てることができるかどうか(assignability)によって区別つくこともできます。any
がなぜ両刃の剣になるのか、本質的に言えば次の2つとなります:
- 全てのデータタイプは、
any
に付与することが可能 -
any
タイプは、全て他のタイプの変数に付与することが可能
unknown
には、前者の機能を持っていますが、後者は持っていません。逆に、never
には前者の機能を持っていませんが、後者の機能を持っています。つまり:
type | 受け入れる | 与える |
---|---|---|
any | ⭕️ | ⭕️ |
unknown | ⭕️ | ❌ |
never | ❌ | ⭕️ |
一個目の機能は、敵を斬る刃と見れば、二個目が自分を傷付ける刃として考えられます。unknown
は一個目しか持っていないので、この意味でany
よりはだいぶ「安全」です。
また、集合の観点から見れば、any
も例外すぎます。any
は上記の2つの機能を持っているということは、全ての他のタイプの親集合でありながら、子集合でもある、という矛盾する結論になります。集合の観点から、any
は明らかにこのタイプシステムに適合していません。タイプチェックは基本的に、付与可能な値の集合でチェックしているので、any
の存在はこの原則を無力化しています。
具体例
仮にYAMLファイルのパーサーを作っているとする:
function parseYaml(yaml: string): any {
//...
}
この例では、any
をリターンしているので、コードベースに伝染する危険性があります。
interface Book {
name: string
author: string
}
const book = parseYaml(`
name: abc
author: hoge
`)
console.log(book.title) // エラーなし
book('read') // エラーなし
仮にbook
にタイプをつけるのを忘れて、parseYaml
がany
をリターンするため、book
に付与する際に、any
となってしまいます。すると上記のようにエラーが全く出なくなくなりますが、実行時にエラーになるでしょう。もしany
ではなく、unknown
を使うと:
function parseYaml(yaml: string): unknown {
//...
}
const book = parseYaml(`
name: abc
author: hoge
`)
console.log(book.title) // Object is of type 'unknown'
book('read') // Object is of type 'unknown'
unknown
をリターン値にすることによって、問題を顕在化することができ、正しいタイプをつけることを開発者に押し付けることができます。
function parseYaml(yaml: string): unknown {
//...
}
const book = parseYaml(`
name: abc
author: hoge
`) as Book
console.log(book.title) // Property 'title' does not exist on type 'Book'
book('read') // Type 'Book' has no call signatures
unknown
でカスタムタイプガード
unknown
の変数に対して、instanceof
演算子とかでタイプ縮小を行うことができます。既存のタイプはもちろん、カスタムタイプの場合も利用可能です:
function isBook(val: unknown): val is Book {
return (
typeof(val) === 'object'
&& val !== null
&& 'name' in val
&& 'author' in val
)
}
function processValue(val: unknown) {
if (isBook(val)) {
// ...
}
}
ダブル断言(double assertion)
unknown
はダブル断言に使われることもあります:
declare const foo: Foo
let barAny = foo as any as Bar
let barUnk = foo as unknown as Bar
結果的に、any
かunknown
かは上記の例では違いがありません。ただ、もしリファクタリングの時にas any
だけになったら、as unknown
の方が安全です(エラーが出るので)。ダブル断言を使う時に、できるかぎりunknown
の方を使いましょう。
{}
とunknown
unknown
が導入される前までは、{}
が代わりに使われることがあります。{}
はnot-null value
とも言われる、null
とundefined
以外の全てのタイプを含まれます。unknown
はもちろん、null
とundefined
を除外していませんので、その意味で{}
の方が若干狭いです。
結論として、意図的に、not-null
だと決めたい場合以外は、unknown
で無難です。
モンキーパッチング(monkey patching)
jsのオブジェクトは、任意の属性を追加することを許すオープンという特徴があります。グローバル変数とかを作りたい時に、window
などのモンキーパッチの形でつけるのが可能です:
window.monkey = 'sarutobi'
const el = document.getElementById('abc')
el.randomProp = '123'
ただ、このモンキーパッチでデータを添付したりするのが良いデザインとは言えません。思いによらないサイドエフェクトがもたらされるからです。
tsの場合になると、タイプチェックの段階でこの機能に大きく制限することになります。つまり、tsには、該当データタイプにあるべき属性がわかるので、モンキーパッチングは基本エラーをもたらします。
document.monkey = 'sarutobi'
// Property 'monkey' does not exist on type 'Document'
この場合にany
を使って解決できそうですが、問題もあります:
(document as any).monkey = 'sarutobi' // OK
(document as any).monky = 'sarutobi' // OK, スペルミス
(document as any).monkey = /sarutobi/ // OK, 違うタイプ
なので、最も良い解答として、モンキーパッチを避けて別の方法でデータを処理することです。ただし、もしどうしても避けられない場合は、インターフェースを使うことができます。既存のインターフェースを重複宣言する場合、合併する効果になります。
interface Document {
monkey: string
}
// もしくはモジュールの場合はグローバルと宣言
declare global {
interface Document {
monkey: string
}
}
document.monkey = 'sarutobi' // OK
また、extends
を使うことで、別の名前で作り直すのはもっとわかりやすく、クラスやタイプにも効果があります:
interface MonkeyDocument extends Document {
monkey: string
}
(document as MonkeyDocument).monkey = 'sarutobi' // OK
いずれにしても、上記はどうしてもの場合のany
より良い解決策に過ぎず、原則としてモンキーパッチングはそんなに容易くさせることではないでほしい、とのことです。
タイプカバー率
any
は使用禁止ではありませんが、使用時の注意点や落とし穴が多いため、できるだけ使用場面を制限したいところです。
仮に開発者が努力してany
をなくそうとしても、any
を完全に消すことがほぼ不可能です。例えば:
- 明示的な
any
タイプ:これはany[]
とか、{[key: string]: any}
とかの使い方によく見られます - サードパーティパッケージから導入された
any
any
がどのくらい存在するのか、一つインスペックションの方法として、type-coverage
で確認することが可能です:
npx type-coverage
9985 / 10117 98.69%
他に、レポートの形で、typescript-coverage-report
も使われます。
いずれにしても、any
以外のタイプのカバー率をトラッキングすることで、開発者にany
を少なくすることを勧めることになるので、最終的にany
の少ないコードに繋がる効果も期待できるでしょう。
tsconfig
ファイルについて
最後はコンパイラーの設定項目についてのリストです。typescriptのバージョンは4.5.5です。よく使われる一部を訳していますが、日本語の公式のサイトで確認することが可能です。
{
"compilerOptions": {
/* プロジェクト関連 */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* 言語環境関連 */
"target": "es2016" /* コンパイラーがtsをjsにコンパイルする時のjsのバージョン */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* @xxxのデコレーターシンタックスを有効に */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* モジュール関連 */
"module": "commonjs" /* nodejsのモジュールのコード、他にES6とかも使われます */,
"rootDir": "./src" /* コンパイラーにプロジェクトのルートフォルダーを教える、デフォルトは./ */,
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* デフォルトは `./node_modules/@types`からタイプ宣言ファイルを探しますが、自分のタイプ宣言ファイルを含めたい時にここに追加可能 */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JSサポート */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* 出力jsファイル関連 */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
"outDir": "./build" /* コンパイルされたjsファイルの置く場所 */,
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* インポート関連 */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* インポートする際にファイル名の大文字小文字を一致にする */,
/* タイプチェックルール関連 */
"strict": true /* 全てのstrick付きの項目をtrue/falseにする */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"exclude": ["node_modules"]
}
終わりに
tsを最近業務で使うことになり、オンラインでコースを見たり、ブログを読んだりして勉強していましたが、やはり自分で何か書いておくのが良いと思い、書き始めました。なんかどんどん内容が増えていって、収束がつかないことに。。
一旦これで終わりにしますが、また何かあれば追加しようと思います。
ではでは。
Discussion