2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ユーザー定義型ガード(型ガード関数)の return 文がゲシュタルト崩壊する時

Last updated at Posted at 2024-01-19

1. はじめに

ユーザー定義型ガードをプロジェクトで使っていく中で、いろいろ試すことができたので、忘備録も兼ねて記事を書いていこうと思います。

Next.jsを使う時、fetchでデータを取得したときなど、そのデータはany型になります。自分がどこからfetchしてきたか分かっていれば期待されるデータの形がわかってるので、型アサーションで強制的に型付けをすることもできますが、それはあまり好まれません。期待するデータが違った場合、取得プロセスでエラーが発生するのではなくてデータを利用する段階でエラーが発生してしまうので、データが悪かったのか、その処理の記述が悪かったのかわかりづらくなるためです。そんな時に使えるのがユーザー定義型ガードであります。

ユーザー定義型ガードの基本から振り返って、プロパティの値がオブジェクトや配列の場合、そして、データ自体がオブジェクトの配列の時など(Array<Object>)の場合、最後に、プロパティがあまりに多い時はどのようにユーザー定義型ガードを実装するかを解説します。

目次

2. 型ガード関数の基本

この章では、ユーザー定義型ガードをどんなところで使うのか、そして、二種類の型ガード関数や実装の際の注意点などを解説します。

2-1. 使用想定場面

はじめに」でお話した通り、Next.jsのプロジェクトにおいては、おそらく、fetchで取得したデータの型について判定する時に使うことが多いと思います。例えば、以下の通りです。

fetch.tsx
type User={
    id:number;
    name:string;
    age:number;
}

export async function getUser(id:number):Promise<User>{
    try{
        const res = await fetch(`${process.env.SERVER_BASE_URL}/api/users/${id}`);
        
        if(res.status !== 200) throw new Error("Failed to fetch the specific user information.");

        const anyData = await res.json();
        return anyData;

    }catch(error){
        console.log(error);
        throw error;
    }
}

ここでは、fetchしたデータをそのままreturnしています。TypeScriptは型エラーこそ出しませんが、anyDataの型を見てみると、any型になっています。
TypeScriptにおけるany型はいわば「お手上げ状態」を表していて、大抵の型ガードをすり抜けてしまう状態であるわけですから、このまま使うのは危険です。
このような場合に、「これは確かにUser型である」と示してあげるためにユーザー定義の型ガード関数が使えるわけです。

なお、fetchする場合に限らずユーザー定義型ガードは、ある値がany型もしくはunknown型になってしまう時に、それが本当のところはどういう型であるかをTypeScriptに教えてあげることで、TypeScriptによる型ガードのサポートを受けたいときに使えます。

2-2. Type Predicate vs Assertion Function

さて、ユーザー定義型ガードを使って、先ほどのコードをより型安全にしていきましょう。

function isUser(value:unknown):value is User{
    if(value == null || typeof value !== "object") return false; //1
    const user = value as Record<keyof User,unknown>;
    return typeof user.id === "number" //2
        && typeof user.name === "string"
        && typeof user.age === "number"; 
}

export async function getUser(id:number):Promise<User>{
    try{
        const res = await fetch(`${process.env.SERVER_BASE_URL}/api/users/${id}`);
        
        if(res.status !== 200) throw new Error("Failed to fetch the specific user information.");

        const user = await res.json();
        if(!isUser(user)) throw new Error("Type of the user is wrong.");
        return user;

    }catch(error){
        console.log(error);
        throw error;
    }
}

1: JavaScriptにおいては、歴史的経緯によってtypeof null === "object"がtrueになるという何とも不思議な挙動が現れます。このため、valueがobject型かどうかを判定する際、nullだけは前もって弾いておかなければなりません。typeof value !== "objcet"という判定で、undefinedも弾けますが、value == nullという緩い判定を使うことで、この段階でundefinedも弾くようにしています。どちらでもあまり差は出ないと思います。
2: また本当ならばuser.hasOwnProperty("id")"id" in userなどのように、それぞれのプロパティがあるかどうかを調べる方が厳密なので好ましくはあるのですが、どちらにしろuserのプロパティにドットアクセスする際にそのプロパティが存在しなければ結果はfalseになって、同時にプロパティの存在判定も行えていることになるので、Recordでの型アサーションでも大丈夫だと思います。

さて、isUserという型ガード関数を用いることで、取得したデータが確かにuser型であることを確認できました。実際に、return userのところでuserの型を調べてみると、User型になっているのがわかると思います。

しかし、待ってください。
isUser(user)の判定がfalseだった場合、今回の実装の例ではわざわざ自分でErrorインスタンスを作ってthrowしています。確かに、isUser()の真偽値判定をそのまま使って、型ガード関数を呼び出している関数で早期returnさせたいだけの時もありますが、型ガード関数がfalseになるような引数を受け取った時はハンドリングされるべきエラーであるはずなので、きちんとエラーを投げたい場合の方が多いと思います。しかし、わざわざ呼び出す側でいちいちエラーを投げる処理を書くのも面倒です。

こんなときに役に立つのが、Assertion Function。
作り方は簡単。返り値の型定義の前に、assertsをつけるだけで大丈夫です。
なお、返り値は不要です。

function isUser(value:unknown):asserts value is User{
    if(value == null || typeof value !== "object") throw new Error("Type of the user is wrong.") ;
    const user = value as Record<keyof User,unknown>;
    if(typeof user.id !== "number"
        || typeof user.name !== "string"
        || typeof user.age !== "number"){
            throw new Error("Type of the user is wrong.");
        }
}

Type Predicate と Assertion Functionのどちらを使うかは、実装する場面によると思います。
多くの場合はAssertion Functionの方を使う方が便利だと思いますが、try catchの例外処理をつくらないコンポーネントで扱いたい場合などには、Type Predicateの方がいいかもしれません。例えば、以下のようなコードの時です。

ShowThread.tsx
"use clinet";

export default function ShowThread({id}:{id:number}){
    const [error,setError] = useState<string>("");
    const [isLoading,setIsLoading]=useState<boolean>(false);
    const [thread,setThread] = useState<Thread>();

    function HandleButton(){
        setIsLoading(true);
        
        fetch(`${process.env.SERVER_BASE_URL}/api/threads/${id}`)
            .then(res=> res.json())
            .then(thread=>{
                if(!isThread(thread)) setError("Type of the specific thread is wrong.");
                setThread(thread);
            })
            .catch(error=>setError("Failed to fetch filtered data."));
            
        setIsLoading(false);
        
    }
    
    return(
        <>
        // show thread UI
        </>
    );
}

2-3. 型ガード関数の注意点

三つほど注意点を述べさせていただきます。
一つ目は、返り値の型定義は必ず「valu is 型」という形にしておかなければなりません。例えば返り値の型をboolean型にしてみると、型を調べて例外処理を行い、安全になったはずの変数がany又はunknown型のままになってしまいます。これは、TypeScriptが関数の定義にまでさかのぼってまで型の絞り込みをしてくれる訳ではないためです。
二つ目は、ユーザー定義型ガードを用いる場合、その責任は開発者がひきうけなければならないということです。具体的に言えば、実装が間違っていたとしてもTypeScriptはそのミスを検出してはくれません。TypeScriptにとって型ガード関数の中はある種ブラックボックスであり、それが真偽値を返すかどうか(又はエラーを投げるかどうか)だけに基づいて型の絞り込みを行っているのです。例えば型ガード関数のなかで、あるはずのないプロパティを確かめるような処理を書いてしまった時でさえ、TypeScriptはそのミスを感知しません。ユーザー定義型ガードは非常に便利である一方で、型安全性を破壊する可能性もあるので、注意が必要です。
三つめは、型ガード関数で確かめられる型は部分型である可能性があるということです。例えば、上のType Predictでの型ガード関数の実装例を振り返ってみましょう。

function isUser(value:unknown):value is User{
    if(value == null || typeof value !== "object") return false; 
    const user = value as Record<keyof User,unknown>;
    return typeof user.id === "number" 
        && typeof user.name === "string"
        && typeof user.age === "number"; 
}

const user:unknown={
    id:1,
    name:"kumamon",
    age:10,
    prefecture:"kumamon"
}

console.log(isUser(user))
\\ true!

これは、TypeScriptが部分型構造を採用していることから発生する挙動ですが、実のところ今回の実装ではあまり害はありません。
isUser関数の中で調べているのは、id,name,ageプロパティを持ち、且つその値の型が正しいかどうかであり、それ以外のプロパティに関しては関心を持たないのです。
しかし、一旦User型として扱ってTypeScriptのサポートを受けるならば、userオブジェクトの中でprefectureプロパティにアクセスしようとすると型エラーが発生します。このため、事実上User型がもつプロパティ以外のプロパティにアクセスすることができないため、たとえ実際のオブジェクトがUser型の部分型であっても問題にはなりません。
(厳密に言えば、型情報にはないが実在するプロパティにアクセスしたとしても、実際にはそのプロパティが存在しているわけですからランタイムエラーは起こりません。しかし、TypeScriptがエラーを出しているので開発者がそれを無視することはないだろうと考えると、事実上そのプロパティにアクセスされることはないと推定できます)

もし、余計なプロパティがあるのが嫌で、厳密にUser型にしたいならば、例えば以下のようにすることができます。

function isUser(value:unknown):value is User{
    if(value == null || typeof value !== "object") return false; 
    const user = value as Record<keyof User,unknown>;
    return typeof user.id === "number" //2
        && typeof user.name === "string"
        && typeof user.age === "number"
        && Object.keys(user).length === 3; 
}

User型がもとめるプロパティを正しく保持していること確認したうえで、userオブジェクトがUser型と同じく三つのプロパティを持っていることを確認することで、厳密にUser型を返却していることが確認できます。

3. プロパティがプリミティブ値以外のとき

ここではプロパティが配列やオブジェクトの時を考えます。
例えば、以下の通りです。

Definitions/Article.tsx
export type Article={
    id:number;
    user:{
        id:number;
        name:string;
        age:number;
    };
    title:string;
    content:string;
    comments:string[];
}

userがオブジェクトであり、commentsがstring型の配列であるとき、どのようにすれば型ガード関数を実装できるでしょうか。
const user = value as Record<keyof Article,unknown>;のままでは、userの値はunknownになってしまうため、idやnameにアクセスすることはできません。
また、commentsについて、配列の中の要素まで潜ってすべてstringであることを確かめるには、どうすればよいのでしょうか?
一つの解決策は以下の通りです。

validater.tsx
function isArticle(value:unknown):value is Article{
    if(value == null || typeof value !== "object") return false;
    const article = value as Record<keyof Article,unknown> & {user:Record<"id"|"name"|"age",unknown>} //1
    return typeof article.id === "number" && typeof article.title === "string" && typeof article.content === "string"
            && typeof article.user.id === "number" && typeof article.user.name === "string" && typeof article.user.age === "number"
            && Array.isArray(article.comments) && article.comments.every(comment => typeof comment === "string") //2
}

const article={
    id:1,
    user:{
        id:1,
        name:"Kirby of the stars",
        age:30
    },
    title:"The thing I hate",
    content:"Caterpillar is disgusting! I can't eat the insect!",
    comments:["I got to know the weakness of the Kirby ZOY!","You are childlish, so you have to be engaged in 'self-displine'."];
}
console.log(isArticle(article));
// The consequence was true!

ポイントは1と2です。
1:これは、交差型(&)によって、userの型を上書きしています。これによって、userオブジェクトはid,name,ageプロパティを持つことがわかり、安全にプロパティにアクセスできます。もし、userをUser型で定義しているならば、{user:Record<keyof User,unknown>}とすることも可能です。
2:これは、まずcommentsが配列であるかどうかを確認することで配列オブジェクトがもつメソッドを呼び出すことができ、それによってeveryメソッドを呼び出しています。everyメソッドでは、全ての要素(comment)がstring型であればtrueを、一つでも型が違えばfalseを返すようにしているので、これで配列の中身まで確認することができました。

4. データがオブジェクトの配列の時

先の章の、「プロパティが配列であった場合」を応用するだけで大丈夫です。
たとえば、以下の通りです。
なお、型ガードisUser関数は実装しているものとします。

function isUsers(value:unknown):value is User[]{
    if(value == null || typeof value !== "object") return false;
    return Array.isArray(value) && value.every(el=>isUser(value));
}

const users:unknown=[
    {
        id:1,
        name:"santoku",
        age:60,
    },{
        id:2,
        name:"toshi",
        age:40
    },{
        id:3,
        name:"natsu",
        age:20,
    }
];

console.log(isUsers(users));
// the result was true!

5. プロパティがあまりにも多い時

今までは、大概オブジェクトのプロパティが少なく済みました。
しかし、現実の実装では、やたらめったらプロパティを多く持つオブジェクトデータを受け取ることがあるかもしれません。
例えば、以下のような型の時です。

type Thread={
    id: number ;
    title: string ;
    content: string ;
    created: string ;
    updated: string ;
    category: string ;
    tag: string ;
    referenceURL:string;
    writerId: number ;
    // ...a vast number of properties.
}

人に依ってはあんまりないかもしれませんが、私にはありました。
そこまで大きいと通常は分割するものですが、プロジェクトでは、allというpathでfetchした時、めっちゃサイズの大きいオブジェクトが返却されました。

そんなとき、いちいち手作業でtypeof obj.key == "some type"なんてしていたら手が痺れるか、連打されたキーボードが叩き割れます。
なにせプロパティの数が多い。
上のThread型で、省略前のプロパティだけを調べようとしても
return typeof obj.id === 'number' && typeof obj.title === 'string' && typeof obj.content === 'string' && typeof obj.created === 'string' && typeof obj.updated === 'string' && typeof obj.category === 'string' && typeof obj.tag === 'string' && typeof obj.referenceURL === 'string' && typeof obj.writerId === 'number'
となります。

キーボードが叩き割れるは冗談としても、やはり何とかして楽に型を調べたいところです。
同じような処理をまとめて行う方法と言えば反復処理ですね。例えば、以下のようなコードが解決策になるかもしれません。

function isObj(value: unknown): value is object {
    return value !== null && typeof value === "object";
}

function isThread(value: unknown): value is Thread {
    if (!isObj(value)) return false;

    const thread = value as Thread;

    for(const [key,value] of Object.entries(thread)){
        if(typeof value !== Thread[key]) return false;
    }
    return true;
}

私「やった!いけたんじゃない?」
TypeScript "'Thread' only refers to a type, but is being used as a value here."
私「???」


はい。
というわけで、これは誤った実装です。
Threadは型であって、値ではなく、TypeScriptからJavaScriptへトランスコンパイルするときはThread型は消え去ります。
しかし、上のコードではThreadをあたかも実在するオブジェクトとして扱っているため、このままではエラーが発生してしまいます。
では、どのようにすればよいのでしょうか?
オブジェクトの値と型が同時に生成されるようなものがあれば便利ですが、そんなものなんてこの世にあるのでしょうか?

私「マぢムリ、モウ寝ヨ。。。」

ひと眠りしたらいいアイデアが思いつきました。そうです、クラスを使えばよいのです。
例えば、以下の通りです。

class Thread{
    id: number = 0;
    title: string = "";
    content: string = "";
    created: string = "";
    updated: string = "";
    category: string = "";
    tag: string = "";
    referenceURL:string="";
    writerId: number = 0;
    // ...a vast number of properties
}

function isObj(value: unknown): value is object {
    return value !== null && typeof value === "object";
}

function isThread(value: unknown): value is Thread {
    if (!isObj(value)) return false;

    const thread = value as Thread;

    const rightThread= new Thread();

    for(const key in rightThread){
        if(!thread.hasOwnProperty(key)) return false;
        if(typeof thread[key as keyof Thread] !== typeof rightThread[key as keyof Thread]) return false;
    }
    
    if(Object.keys(thread).length !== Object.keys(rightThread).length) return false;

    return true;
}


// Example usage:
const myThread: unknown = {
    id: 1,
    title: "Thread Title",
    content: "Thread Content",
    created: "2024-01-19",
    category: "Programming",
    tag: "TypeScript",
    updated: "2024-01-20",
    referenceURL:"some/path",
    writerId: 123,
    // ...a vast number of properties
};

if (isThread(myThread)) {
    // myThreadはThread型として扱われる
    console.log(myThread.id);
} else {
    // myThreadはThread型ではない
    console.log("Not a valid Thread object.");
}
// the logged output was 1! succeeded in implementing!.

実は、クラスは値にもなり、型にもなるのです。
クラス宣言でThreadというクラスをつくった時、実は同時にThread型(正確には、Threadクラスのインスタンスの型)も作っているのです。
例えば、クラスとして宣言しているはずのThreadですが、以下のようにオブジェクトの型として使うこともできます。

const myThread:Thread = {
    id: 1,
    title: "Thread Title",
    content: "Thread Content",
    created: "2024-01-19",
    category: "Programming",
    tag: "TypeScript",
    updated: "2024-01-20",
    referenceURL:"some/path",
    writerId: 123,
    // ...a vast number of properties
};

ここでのThreadはオブジェクトであり、Threadクラスのインスタンスではありません。しかし、TypeScriptは部分型構造を採用しているため、Threadクラスのインスタンスの特徴を満たすものであれば、Thread型として扱えるのです1。このため、特にmyThreadの型定義の際にエラーが出ることはなく、しかもプロパティが間違えていたらちゃんとエラーを出してくれるので、型エイリアスやインターフェースでThread型を宣言した時と同じ挙動が現れています。とても便利な仕様となっています。
さて、クラスの特徴を踏まえたうえで、先ほどのコードをより詳しく見てみましょう。

    const thread = value as Thread; //1
    const rightThread= new Thread(); //2
    for(const key in rightThread){
        if(!thread.hasOwnProperty(key)) return false;
        if(typeof thread[key as keyof Thread] !== typeof rightThread[key as keyof Thread]) return false;
    }

Threadクラスは1で型アサーションとして、2でクラスのインスタンス化として扱っています。
そしてThreadのインスタンスオブジェクトからキーを取り出し、threadが正しくキーを持っているか、そしてその値の型まで等しいかを、反復処理でまとめて判定しています。
これでたくさんのプロパティを持つオブジェクトの型を判定する型ガード関数を、うまい具合に実装できました。
プロパティがオブジェクトの場合や配列の場合は別途条件制御を書かなければなりませんが、それでも値がプリミティブ値だけなら上記の通りだけで済むので、だいぶ簡単になったと思います。

6. おわりに

いかがでしたでしょうか。
ユーザー定義型ガードの基本的な解説から立ち上がり、TypeScriptの深めの仕様まで使った拡張的な実装まで解説しました。
これがどのような形でか、みなさんの役に立てば幸いでございます。

  1. ただしmyThreadはThreadクラスをインスタンス化して作ったものではないため、myThread instanceof Thread はfalseとなります

2
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?