🤨

TypeScript 入門ノート

2022/03/27に公開

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')

通常では同じタイプの変数にしか値を付与できませんが、例外として、nullundefinedがすべてのタイプの「子タイプ」のため、他のタイプに与えることが可能。

let str:string = 'hoge'
str = null // ok
str= undefined // ok

これを防ぐために、tsconfigファイルに、"strickNullChecks":trueにすれば、nullundefinedが自分自身か、voidに与えることしかできなくなります。

また、numberbigintは両方数字ですが、タイプ上の互換性・包括関係がありません。

配列

配列も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は文字通り空白の意味で、他のタイプのデータと互換性・包括関係がありません。例外として、strictNullChecksfalseの時に、nullundefinedを付与することが可能。なので、実際の使用中に、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とかを書かなくて良いのです。

nullundefinedとの比較

nevernullundefinedと同じく、任意の他のタイプの変数に付与することが可能。ただ、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
  }
}

すると、もし誰かがFoostring|number|booleanに変更したら、else分岐では、booleanneverに付与することができないので、ここでエラーが出ます。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モードでは、nullundefinedも付与できません(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の場合はnullundefinedが除外されます。

let upperCaseObject: Object
upperCaseObject = 1 // ok
upperCaseObject = 'a' // ok
upperCaseObject = true // ok
upperCaseObject = null // ts(2322)
upperCaseObject = undefined // ts(2322)
upperCaseObject = {} // ok

しかし、Objectobjectは単純に親タイプ->子タイプというわけではありません:

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

ちょっと変な結果かもしれませんが、Objectobjectの親タイプでもあり、子タイプでもあります。

なお、{}Objectと同じ位置付けとなり、お互いに入れ替えることが可能です。

結論として、{}Objectobjectよりタイプの範囲が広く、原始タイプとオブジェクトタイプの値を表せますが、objectはオブジェクトタイプの値のみとなります。いずれも、strickNullChecksモードでば、nullundefinedは除外されます。

補足ですが、typeof []で実行すると、objectが返ってきます。配列も、objectObjectまたは{}に付与することが可能です。

タイプ推測(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除外

!をつけることで、この値はnullundefinedではありません、と断言しています。

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)

タイプ推測の節で例をあげましたが、letconstで変数を定義することによって、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

こちらの例で見ると、objconstを使っていますが、タイプは{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として推測されますが、仮にnullundefinedとして初期化すると(undefinedは付与しない場合と同様だが)、strict null checkfalseの時に、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で初期化して、他の変数に付与するケースがあまりないでしょうが、注意点として、x2y2は拡張されていなく、nullundefinedのままです。

タイプ縮小(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のタイプを推測できます。ブロック内では、paramunknownから、stringへ縮小されました。条件判断でタイプを縮小することがtsにおいて非常に有効な手段です。

もう一つよく使う例として、配列からnullundefinedを除外することです。単純にarr.filter(el => el !== null && el!== undefined)でかけても、arrのタイプ定義からnullundefinedを除外できません。この場合はタイプガードを利用します:

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 nullobjectなので、実際にタイプ縮小ができていません。

また、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という共通属性に対して、同じ合併操作が行われるので、Cxタイプが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

この場合Cxのタイプが、{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  
}

上記の例で、nameneverタイプに付与できないとのエラーが出てきます。つまり、同じ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関数以外に、labelsizeを持っていますが、エラーとなりません。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

こちらの例のように、引数vVector2Dと決めていますが、構造的に、Vector2DNamedVector2Dに互換性があるので、問題ありませんが、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が「拡張」との意味なのに、なぜ「制約」をかけているだろう、と違和感があるかもしれません。アヒルタイプの節で説明しましたが、ここのTlength以外にも属性を持つことが可能なので、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]
}

ただ、Parialshallowであり、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

ExcludeExtract

先ほど触れましたが、定義は以下となります

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

言葉通り、省略との意味です。定義は、先ほどのPickExcludeを併用しています:

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の節で触れましたが、nullundefined以外のタイプを指すことです

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をリターンしています。すると、fnprocessXを呼び出す時にエラーになります。

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-ignoreany以外の選択肢として考える

ただの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

nulltry/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.. */

先ほどの例では、resany[]として宣言していなく、推測されていますので、このルールに当たるはずですが、エラーがありません。

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にタイプをつけるのを忘れて、parseYamlanyをリターンするため、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

結果的に、anyunknownかは上記の例では違いがありません。ただ、もしリファクタリングの時にas anyだけになったら、as unknownの方が安全です(エラーが出るので)。ダブル断言を使う時に、できるかぎりunknownの方を使いましょう。

{}unknown

unknownが導入される前までは、{}が代わりに使われることがあります。{}not-null valueとも言われる、nullundefined以外の全てのタイプを含まれます。unknownはもちろん、nullundefinedを除外していませんので、その意味で{}の方が若干狭いです。

結論として、意図的に、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