Urata Daiki / @daiki7nohe
Urata Daiki / @daiki7nohe
php artisan breeze:install vue --typescript
php artisan breeze:install vue --typescript
class UsersController { public function index() { $users = User::active() ->orderByName() ->get(['id', 'name', 'email']); return Inertia::render('Users', [ 'users' => $users ]); } }
class UsersController { public function index() { $users = User::active() ->orderByName() ->get(['id', 'name', 'email']); return Inertia::render('Users', [ 'users' => $users ]); } }
<!-- Users.vue --> <script setup lang="ts"> import Layout from './Layout' import { Link, Head } from '@inertiajs/vue3' defineProps({ users: User[] }) </script> <template> <Layout> <Head title="Users" /> <div v-for="user in users" :key="user.id"> <Link :href="`/users/${user.id}`"> {{ user.name }} </Link> <div>{{ user.email }}</div> </div> </Layout> </template>
<!-- Users.vue --> <script setup lang="ts"> import Layout from './Layout' import { Link, Head } from '@inertiajs/vue3' defineProps({ users: User[] }) </script> <template> <Layout> <Head title="Users" /> <div v-for="user in users" :key="user.id"> <Link :href="`/users/${user.id}`"> {{ user.name }} </Link> <div>{{ user.email }}</div> </div> </Layout> </template>
class UsersController { public function index() { $users = User::active() ->orderByName() ->get(['id', 'name', 'email']); return Inertia::render('Users', [ 'users' => $users ]); } }
class UsersController { public function index() { $users = User::active() ->orderByName() ->get(['id', 'name', 'email']); return Inertia::render('Users', [ 'users' => $users ]); } }
<script setup lang="ts"> import Layout from './Layout' import { Link, Head } from '@inertiajs/vue3' defineProps({ users: Users[] }) </script> <template> <Layout> <Head title="Users" /> <div v-for="user in users" :key="user.id"> <Link :href="`/users/${user.id}`"> {{ user.name }} </Link> <div>{{ user.email }}</div> </div> </Layout> </template>
<script setup lang="ts"> import Layout from './Layout' import { Link, Head } from '@inertiajs/vue3' defineProps({ users: Users[] }) </script> <template> <Layout> <Head title="Users" /> <div v-for="user in users" :key="user.id"> <Link :href="`/users/${user.id}`"> {{ user.name }} </Link> <div>{{ user.email }}</div> </div> </Layout> </template>
// Migration file Schema::create('posts', function (Blueprint $table) { $table->id(); $table->string('title'); $table->string('body'); $table->timestamps(); }); // Model class class Post extends Model {}
// Migration file Schema::create('posts', function (Blueprint $table) { $table->id(); $table->string('title'); $table->string('body'); $table->timestamps(); }); // Model class class Post extends Model {}
type Post = { id: number; title: string; body: string; created_at?: string; updated_at?: string; };
type Post = { id: number; title: string; body: string; created_at?: string; updated_at?: string; };
public function up() { Schema::table('posts', function (Blueprint $table) { $table->string('slug')->nullable()->after('body'); }); } public function down() { Schema::table('posts', function (Blueprint $table) { $table->dropColumn('slug'); }); }
public function up() { Schema::table('posts', function (Blueprint $table) { $table->string('slug')->nullable()->after('body'); }); } public function down() { Schema::table('posts', function (Blueprint $table) { $table->dropColumn('slug'); }); }
public function up() { Schema::table('posts', function (Blueprint $table) { $table->renameColumn('body', 'content'); }); } public function down() { Schema::table('posts', function (Blueprint $table) { $table->renameColumn('content', 'body'); }); }
public function up() { Schema::table('posts', function (Blueprint $table) { $table->renameColumn('body', 'content'); }); } public function down() { Schema::table('posts', function (Blueprint $table) { $table->renameColumn('content', 'body'); }); }
class PostsController { public function index() { $posts = Post::all(); return Inertia::render('Posts', [ 'posts' => $posts ]); } }
class PostsController { public function index() { $posts = Post::all(); return Inertia::render('Posts', [ 'posts' => $posts ]); } }
<!-- Posts.vue --> <script setup lang="ts"> import { Post } from '@/types/model' import { Link, Head } from '@inertiajs/vue3' defineProps({ posts: Post[] }) </script> <template> <div> <h1>投稿データ一覧</h1> <ul> <li v-for="post in posts" :key="post.id"> <h2>{{ post.title }}</h2> <p>{{ post.conetnt }}</p> </li> </ul> </div> </template>
<!-- Posts.vue --> <script setup lang="ts"> import { Post } from '@/types/model' import { Link, Head } from '@inertiajs/vue3' defineProps({ posts: Post[] }) </script> <template> <div> <h1>投稿データ一覧</h1> <ul> <li v-for="post in posts" :key="post.id"> <h2>{{ post.title }}</h2> <p>{{ post.conetnt }}</p> </li> </ul> </div> </template>
[ { "id": 1, "title": "タイトル1", "content": "内容1", "slug": "post-1", "created_at": "2023-06-18 10:00:00", "updated_at": "2023-06-18 12:30:00" }, { "id": 2, "title": "タイトル2", "content": "内容2", "slug": "post-2", "created_at": "2023-06-19 09:15:00", "updated_at": "2023-06-20 14:20:00" }, { "id": 3, "title": "タイトル3", "content": "内容3", "slug": "post-3", "created_at": "2023-06-21 16:45:00", "updated_at": "2023-06-21 18:10:00" } ]
[ { "id": 1, "title": "タイトル1", "content": "内容1", "slug": "post-1", "created_at": "2023-06-18 10:00:00", "updated_at": "2023-06-18 12:30:00" }, { "id": 2, "title": "タイトル2", "content": "内容2", "slug": "post-2", "created_at": "2023-06-19 09:15:00", "updated_at": "2023-06-20 14:20:00" }, { "id": 3, "title": "タイトル3", "content": "内容3", "slug": "post-3", "created_at": "2023-06-21 16:45:00", "updated_at": "2023-06-21 18:10:00" } ]
<script setup lang="ts"> import { Post } from '@types/model' import { Link, Head } from '@inertiajs/vue3' defineProps({ posts: Post[] }) </script> <template> <div> <h1>投稿データ一覧</h1> <ul> <li v-for="post in posts" :key="post.id"> <h2>{{ post.title }}</h2> <p>{{ post.conetnt }}</p> </li> </ul> </div> </template>
<script setup lang="ts"> import { Post } from '@types/model' import { Link, Head } from '@inertiajs/vue3' defineProps({ posts: Post[] }) </script> <template> <div> <h1>投稿データ一覧</h1> <ul> <li v-for="post in posts" :key="post.id"> <h2>{{ post.title }}</h2> <p>{{ post.conetnt }}</p> </li> </ul> </div> </template>
enum Color: int { case RED = 1; case GREEN = 2; case BLUE = 3; }
enum Color: int { case RED = 1; case GREEN = 2; case BLUE = 3; }
enum Color { RED = 1, GREEN = 2, BLUE = 3 }
enum Color { RED = 1, GREEN = 2, BLUE = 3 }
// routes/web.php Route::get('/dashboard', DashboardController::class) ->name('dashboard'); // app/Http/Controllers/DashboardController.php class DashboardController extends Controller { public function __invoke() { return Inertia::render('Dashboard'); } }
// routes/web.php Route::get('/dashboard', DashboardController::class) ->name('dashboard'); // app/Http/Controllers/DashboardController.php class DashboardController extends Controller { public function __invoke() { return Inertia::render('Dashboard'); } }
<script setup lang="ts"> import { Link } from '@inertiajs/vue3'; </script> <template> <div class="container"> <a href="/dashboard">Dashboard</a> <!-- ziggy --> <a :href="route('dashboard')">Dashboard</a> <!-- Inertia + ziggy --> <Link :href="route('dashboard')">Dashboard</Link> </div> </template>
<script setup lang="ts"> import { Link } from '@inertiajs/vue3'; </script> <template> <div class="container"> <a href="/dashboard">Dashboard</a> <!-- ziggy --> <a :href="route('dashboard')">Dashboard</a> <!-- Inertia + ziggy --> <Link :href="route('dashboard')">Dashboard</Link> </div> </template>
Enum/ルーティング未対応だったり、そもそもComposerパッケージ…
$ php artisan model:show Post App\Models\Post .................................................. Database ................................................... mysql Table ...................................................... posts Attributes ........................................... type / cast id increments, unique ...................... bigint unsigned / int title ................................................ string(255) body ................................................. string(255) type ................................. string / App\Enums\PostType user_id .......................................... bigint unsigned created_at nullable .......................... datetime / datetime updated_at nullable .......................... datetime / datetime Relations ........................................................ author BelongsTo ................................. App\Models\User Observers ........................................................
$ php artisan model:show Post App\Models\Post .................................................. Database ................................................... mysql Table ...................................................... posts Attributes ........................................... type / cast id increments, unique ...................... bigint unsigned / int title ................................................ string(255) body ................................................. string(255) type ................................. string / App\Enums\PostType user_id .......................................... bigint unsigned created_at nullable .......................... datetime / datetime updated_at nullable .......................... datetime / datetime Relations ........................................................ author BelongsTo ................................. App\Models\User Observers ........................................................
$ php artisan model:show Post --json > Post.json
$ php artisan model:show Post --json > Post.json
model:show
コマンドを叩く{ "class": "App\\Models\\Post", "database": "mysql", "table": "posts", "policy": null, "attributes": [ { "name": "id", "type": "bigint unsigned", "increments": true, "nullable": false, "default": null, "unique": true, "fillable": false, "hidden": false, "appended": null, "cast": "int" }, { "name": "title", "type": "string(255)", "increments": false, "nullable": false, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": null }, { "name": "body", "type": "string(255)", "increments": false, "nullable": false, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": null }, { "name": "type", "type": "string", "increments": false, "nullable": false, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": "App\\Enums\\PostType" }, { "name": "user_id", "type": "bigint unsigned", "increments": false, "nullable": false, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": null }, { "name": "created_at", "type": "datetime", "increments": false, "nullable": true, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": "datetime" }, { "name": "updated_at", "type": "datetime", "increments": false, "nullable": true, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": "datetime" } ], "relations": [ { "name": "author", "type": "BelongsTo", "related": "App\\Models\\User" } ], "observers": [] }
{ "class": "App\\Models\\Post", "database": "mysql", "table": "posts", "policy": null, "attributes": [ { "name": "id", "type": "bigint unsigned", "increments": true, "nullable": false, "default": null, "unique": true, "fillable": false, "hidden": false, "appended": null, "cast": "int" }, { "name": "title", "type": "string(255)", "increments": false, "nullable": false, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": null }, { "name": "body", "type": "string(255)", "increments": false, "nullable": false, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": null }, { "name": "type", "type": "string", "increments": false, "nullable": false, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": "App\\Enums\\PostType" }, { "name": "user_id", "type": "bigint unsigned", "increments": false, "nullable": false, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": null }, { "name": "created_at", "type": "datetime", "increments": false, "nullable": true, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": "datetime" }, { "name": "updated_at", "type": "datetime", "increments": false, "nullable": true, "default": null, "unique": false, "fillable": false, "hidden": false, "appended": null, "cast": "datetime" } ], "relations": [ { "name": "author", "type": "BelongsTo", "related": "App\\Models\\User" } ], "observers": [] }
$ php artisan route:list --json > route.json
$ php artisan route:list --json > route.json
[ { "domain": null, "method": "GET|HEAD", "uri": "\/", "name": null, "action": "Closure", "middleware": [ "web" ] }, { "domain": null, "method": "GET|HEAD", "uri": "dashboard", "name": "dashboard", "action": "App\\Http\\Controllers\\DashboardController", "middleware": [ "web", "App\\Http\\Middleware\\Authenticate", "Illuminate\\Auth\\Middleware\\EnsureEmailIsVerified" ] }, ]
[ { "domain": null, "method": "GET|HEAD", "uri": "\/", "name": null, "action": "Closure", "middleware": [ "web" ] }, { "domain": null, "method": "GET|HEAD", "uri": "dashboard", "name": "dashboard", "action": "App\\Http\\Controllers\\DashboardController", "middleware": [ "web", "App\\Http\\Middleware\\Authenticate", "Illuminate\\Auth\\Middleware\\EnsureEmailIsVerified" ] }, ]
import { Engine } from "php-parser"; import fs from "fs"; const parser = new Engine({}); const phpFile = fs.readFileSync("./example.php"); // '<?php echo "Hello World";' console.log(parser.parseCode(phpFile));
import { Engine } from "php-parser"; import fs from "fs"; const parser = new Engine({}); const phpFile = fs.readFileSync("./example.php"); // '<?php echo "Hello World";' console.log(parser.parseCode(phpFile));
AST output
{ 'kind': 'program', 'children': [ { 'kind': 'echo', 'arguments': [ { 'kind': 'string', 'isDoubleQuote': true, 'value': 'Hello World' } ] } ] }
{ 'kind': 'program', 'children': [ { 'kind': 'echo', 'arguments': [ { 'kind': 'string', 'isDoubleQuote': true, 'value': 'Hello World' } ] } ] }
import ts from "typescript" function makeFactorialFunction() { const functionName = ts.factory.createIdentifier("factorial"); const paramName = ts.factory.createIdentifier("n"); const parameter = ts.factory.const parameter = ts.factory.createParameterDeclaration( /*modifiers*/ undefined, /*dotDotDotToken*/ undefined, paramName ); // 省略 return ts.factory.createFunctionDeclaration( /*modifiers*/ [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], /*asteriskToken*/ undefined, functionName, /*typeParameters*/ undefined, [parameter], /*returnType*/ ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), ts.factory.createBlock(statements, /*multiline*/ true) ); }
import ts from "typescript" function makeFactorialFunction() { const functionName = ts.factory.createIdentifier("factorial"); const paramName = ts.factory.createIdentifier("n"); const parameter = ts.factory.const parameter = ts.factory.createParameterDeclaration( /*modifiers*/ undefined, /*dotDotDotToken*/ undefined, paramName ); // 省略 return ts.factory.createFunctionDeclaration( /*modifiers*/ [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], /*asteriskToken*/ undefined, functionName, /*typeParameters*/ undefined, [parameter], /*returnType*/ ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword), ts.factory.createBlock(statements, /*multiline*/ true) ); }
const resultFile = ts.createSourceFile( "someFileName.ts", "", ts.ScriptTarget.Latest, /*setParentNodes*/ false, ts.ScriptKind.TS ); const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); const result = printer.printNode(ts.EmitHint.Unspecified, makeFactorialFunction(), resultFile); console.log(result);
const resultFile = ts.createSourceFile( "someFileName.ts", "", ts.ScriptTarget.Latest, /*setParentNodes*/ false, ts.ScriptKind.TS ); const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); const result = printer.printNode(ts.EmitHint.Unspecified, makeFactorialFunction(), resultFile); console.log(result);
export function factorial(n): number { if (n <= 1) { return 1; } return n * factorial(n - 1); }
export function factorial(n): number { if (n <= 1) { return 1; } return n * factorial(n - 1); }
インストール
$ npm install -D @7nohe/laravel-typegen
$ npm install -D @7nohe/laravel-typegen
package.json編集
{ "scripts": { "typegen": "laravel-typegen" }, }
{ "scripts": { "typegen": "laravel-typegen" }, }
実行
$ npm run typegen
$ npm run typegen
<?php namespace App\Models; use App\Enums\PostType; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Post extends Model { use HasFactory; protected $casts = [ 'type' => PostType::class, ]; public function author() { return $this->belongsTo(User::class); } }
<?php namespace App\Models; use App\Enums\PostType; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Post extends Model { use HasFactory; protected $casts = [ 'type' => PostType::class, ]; public function author() { return $this->belongsTo(User::class); } }
export type Post = { id: number; title: string; body: string; type: PostType; user_id: number; created_at?: string; updated_at?: string; author?: User; };
export type Post = { id: number; title: string; body: string; type: PostType; user_id: number; created_at?: string; updated_at?: string; author?: User; };
<?php declare(strict_types=1); namespace App\Enums; enum PostType: int { case Public = 10; case Private = 20; }
<?php declare(strict_types=1); namespace App\Enums; enum PostType: int { case Public = 10; case Private = 20; }
<?php declare(strict_types=1); namespace App\LaravelEnums; use BenSampo\Enum\Enum; final class PostType extends Enum { const Public = 10; const Private = 20; }
<?php declare(strict_types=1); namespace App\LaravelEnums; use BenSampo\Enum\Enum; final class PostType extends Enum { const Public = 10; const Private = 20; }
export enum PostType { Public = 10, Private = 20 }
export enum PostType { Public = 10, Private = 20 }
// routes/web.php Route::resource('posts', PostsController::class);
// routes/web.php Route::resource('posts', PostsController::class);
$ php artisan route:list GET|HEAD posts .................. posts.index › PostsController@index POST posts .................. posts.store › PostsController@store GET|HEAD posts/create ......... posts.create › PostsController@create GET|HEAD posts/{post} ............. posts.show › PostsController@show PUT|PATCH posts/{post} ......... posts.update › PostsController@update DELETE posts/{post} ....... posts.destroy › PostsController@destroy GET|HEAD posts/{post}/edit ........ posts.edit › PostsController@edit
$ php artisan route:list GET|HEAD posts .................. posts.index › PostsController@index POST posts .................. posts.store › PostsController@store GET|HEAD posts/create ......... posts.create › PostsController@create GET|HEAD posts/{post} ............. posts.show › PostsController@show PUT|PATCH posts/{post} ......... posts.update › PostsController@update DELETE posts/{post} ....... posts.destroy › PostsController@destroy GET|HEAD posts/{post}/edit ........ posts.edit › PostsController@edit
export type RouteParams = { "dashboard": {}; "posts.index": {}; "posts.store": {}; "posts.create": {}; "posts.show": { post: string; }; "posts.update": { post: string; }; "posts.destroy": { post: string; }; "posts.edit": { post: string; }; };
export type RouteParams = { "dashboard": {}; "posts.index": {}; "posts.store": {}; "posts.create": {}; "posts.show": { post: string; }; "posts.update": { post: string; }; "posts.destroy": { post: string; }; "posts.edit": { post: string; }; };
さらにziggy-jsのroute()
の型を上書きする型定義ファイル(route.d.ts)を生成
// OK route('posts.show', { post: post.id }) // TS Error: Argument of type '{ id: string; }' is not assignable to parameter of type '{ post: string; }' route('posts.show', { id: post.id }) // TS Error: Argument of type '"post.show"' is not assignable to parameter of type 'keyof RouteParams'.ts route('posts.detail', { post: post.id })
// OK route('posts.show', { post: post.id }) // TS Error: Argument of type '{ id: string; }' is not assignable to parameter of type '{ post: string; }' route('posts.show', { id: post.id }) // TS Error: Argument of type '"post.show"' is not assignable to parameter of type 'keyof RouteParams'.ts route('posts.detail', { post: post.id })
<?php namespace App\Http\Requests\Auth; use Illuminate\Foundation\Http\FormRequest; class LoginRequest extends FormRequest { public function rules() { return [ 'email' => ['required', 'string', 'email'], 'password' => ['required', 'string'], ]; } }
<?php namespace App\Http\Requests\Auth; use Illuminate\Foundation\Http\FormRequest; class LoginRequest extends FormRequest { public function rules() { return [ 'email' => ['required', 'string', 'email'], 'password' => ['required', 'string'], ]; } }
export type LoginRequest = { email: string; password: string; };
export type LoginRequest = { email: string; password: string; };
const res = await axios.post< LoginResponse, AxiosResponse<LoginResponse, LoginRequest>, LoginRequest >("/login", { email: "test@example.com", password: "password", });
const res = await axios.post< LoginResponse, AxiosResponse<LoginResponse, LoginRequest>, LoginRequest >("/login", { email: "test@example.com", password: "password", });
POSTリクエストの型強化に利用可能
規模
技術スタック
よかった点
つまずいた点
public function fullName(): Attribute { return new Attribute( get: fn () => $this->first_name .' ' . $this->last_name ); }
public function fullName(): Attribute { return new Attribute( get: fn () => $this->first_name .' ' . $this->last_name ); }
type UserWithFullName = User & { full_name: string; };
type UserWithFullName = User & { full_name: string; };
Intersection Typesなどで補足する必要あり
/lang en.json ja.json
/lang en.json ja.json
import { createI18n } from 'vue-i18n' import enUS from './locales/en-US.json' // 'en-US'がベースとなり他言語ファイルの翻訳漏れに気づける! type MessageSchema = typeof enUS const i18n = createI18n<[MessageSchema], 'en-US' | 'ja-JP'>({ locale: 'en-US', messages: { 'en-US': enUS } })
import { createI18n } from 'vue-i18n' import enUS from './locales/en-US.json' // 'en-US'がベースとなり他言語ファイルの翻訳漏れに気づける! type MessageSchema = typeof enUS const i18n = createI18n<[MessageSchema], 'en-US' | 'ja-JP'>({ locale: 'en-US', messages: { 'en-US': enUS } })
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StorePostRequest extends FormRequest { public function rules(): array { return [ 'title' => 'required|string|max:255', 'body' => 'required|string', ]; } }
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; class StorePostRequest extends FormRequest { public function rules(): array { return [ 'title' => 'required|string|max:255', 'body' => 'required|string', ]; } }
export const StorePostRequest = z.object({ title: z.string().max(255).nonempty(), body: z.string().nonempty() });
export const StorePostRequest = z.object({ title: z.string().max(255).nonempty(), body: z.string().nonempty() });
LaravelからTypeScriptの型自動生成でバックエンドとフロントエンドのミスコミュニケーションを軽減できる
小規模やInertiaアプリではこの手法がスピードを落とさず型安全な開発ができる
LaravelからTypeScriptの型生成はartisanコマンド、PHPパーサー、TypeScript Compiler APIの組み合わせで実現できる