Với hơn một nửa công việc hàng ngày là làm web, Python và JavaScript là hai ngôn ngữ mình dùng nhiều. Đây vốn là hai ngôn ngữ "kiểu động" (dynamic type), nhưng mình vẫn tận dụng hệ thống kiểu (type system) của chúng để viết như với một ngôn ngữ "kiểu tĩnh" (static type). Sau một thời gian thì mình rút ra được một số kinh nghiệm để làm việc với hai hệ này.
Thật ra, ngôn ngữ JavaScript không có ký hiệu kiểu, nên nói chính xác hơn là mình đang dùng TypeScript chứ không phải JavaScript. Trước hết, người đã làm quen với JavaScript mà được giới thiệu về TypeScript thì sẽ bật lên một câu hỏi, tại sao chúng là ngôn ngữ dynamic type thì lại mất công viết như static type làm gì. Đó là vì những ích lợi sau:
- Giúp các công cụ kiểm tra (static analysis) như MyPy,
tsc
hiểu được code mình, để phát hiện được bug tiềm tàng trong những trường hợp ngách mà mình chưa test. - Giúp các trình soạn thảo (code editor, IDE) hiểu được biến đang có kiểu gì, để đưa ra gợi ý autocomplete đúng hơn.
Sau đây là hình minh họa cho thấy trình soạn thảo đưa được gợi ý chính xác, cùng giải thích kiểu dữ liệu cho đoạn code VueJS, nhờ việc ứng dụng TypeScript:
Vậy hệ thống kiểu của hai ngôn ngữ này hay ho như thế nào:
1. Độ tích hợp
Bên TypeScript thì các kí hiệu kiểu không thuộc ngôn ngữ JavaScript. Sau quá trình build thì các kí hiệu đó sẽ bị tước sạch. Code cuối cùng cho trình duyệt chạy chỉ là code JavaScript thuần. Bên Python thì các kí hiệu kiểu vẫn thuộc về ngôn ngữ Python. Code mà trình thông dịch tiếp nhận để chạy vẫn còn các kí hiệu đó, và truy cập được khi đang chạy. Mức độ tích hợp như Python mang lại lợi ích lớn là cho phép dùng các kí hiệu kiểu cho việc kiểm tra, làm sạch dữ liệu đầu vào (validation).
Một thư viện nổi tiếng đã tận dụng lợi thế này là Pydantic
. Để làm rõ ưu điểm này, hãy đặt ra một bài toán làm ví dụ. Bài toán đó là, ứng dụng của ta trao đổi dữ liệu với một dịch vụ bên ngoài, và ta cần kiểm tra để chắc chắn dữ liệu nhận về cần có dạng như sau, trước khi dùng chúng:
{
name: 'Kaka', // A string
age: 16 // An integer number
}
Nếu dùng Pydantic để validate, ta sẽ định nghĩa schema như sau:
from pydantic import BaseModel
class Person(BaseModel):
name: str
age: int
def get_person() -> Person:
# Call some API to get data
response = call_api()
return Person.model_validate(response)
Thư viện validation nổi tiếng nhất bên JS/TS là zod (ngoài ra còn có superstruct
). Với zod thì ta viết như sau:
import { z } from 'zod'
const PersonSchema = z.object({
name: z.string(),
age: z.number()
})
type Person = z.infer<typeof PersonSchema>
async function fetchUser(): Person {
// Call some API to get data
const response = await callApi()
return PersonSchema.parse(response)
}
Ta có thể thấy ngay bên zod phải dùng thêm hàm của thư viện để mô tả kiểu dữ liệu mong muốn, và để có được kí hiệu kiểu của đối tượng mới định nghĩa này, ta phải thêm bước z.infer
, trong khi bên Pydantic thì chỉ dùng tên kiểu str
, int
có sẵn của ngôn ngữ, và lớp mô tả schema cũng dùng làm kiểu dữ liệu mới luôn.
Code bên zod cũng bắt đầu dài dòng, khó hơn khi nhu cầu vượt ra ngoài những kiểu cơ bản. Chẳng hạn khi ta muốn thêm field có kiểu UUID
, ngày tháng, với Pydantic thì ta sẽ viết:
from datetime import datetime
from uuid import UUID
class Person(BaseModel):
id: UUID
name: str
age: int
registered_at: datetime
Bên zod:
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
registered_at: z.coerce.date()
// Uhm, don't know how to decribe `id` field,
// unless we have custom code for `UUID` type.
})
2. Biến hóa
Tuy thua về độ tích hợp như trên, TypeScript lại có cái thú vị khi có những phương tiện để biến đổi, xào nấu kiểu. Ví dụ:
- Từ kiểu bao trùm, lấy ra kiểu của một thành viên:
type Person = { age: number; name: string; alive: boolean };
type Age = Person["age"];
// `Age` type is `number`
- Định nghĩa kiểu mới bằng cách đổi tên các field theo một công thức nào đó. Ví dụ tôi có:
type Condition = {
temperature: number
humidity: number
// More fields...
}
Tôi có thể định nghĩa kiểu mới với các field avg_temperature
, avg_humidity
... như sau:
type AvgRemap<T> = {
[Property in keyof T as `avg_${string & Property}`]: T[Property]
}
type AvgCondition = AvgRemap<Condition>
3. Độ hoàn thiện
Điểm thứ 2 có vẻ không quan trọng lắm (có thì tốt, không có cũng không sao), nhưng điểm thứ 3 là một bất lợi của Python. TypeScript được phát triển độc lập với JavaScript nên tốc độ đổi mới nhanh, người dùng không cần chờ JavaScript lên phiên bản mới để dùng được những tính năng mới của TypeScript. Bên Python thì các hệ thống kiểu thuộc ngôn ngữ luôn nên muốn dùng tính năng mới thì phải chờ Python cập nhật. Ví dụ là cú pháp "generic". Giả sử ta muốn định nghĩa một hàm, nhận vào một danh sách giá trị, trả về thành viên đầu tiên. Vì ta không cần giới hạn kiểu của thành viên (là số, chuỗi, ngày tháng hay gì cũng được), mà chỉ cần ràng buộc kiểu của đầu ra theo kiểu đầu vào, nên ta dùng "generic":
from typing import TypeVar
T = TypeVar('T')
def first(items: list[T]) -> T:
return items[0]
Ở phiên bản Python mới hơn thì ta có thể dùng cú pháp ngắn gọn hơn:
def first[T](items: list[T]) -> T:
return items[0]
Nhưng muốn dùng cú pháp này thì phải nâng cấp lên Python 3.12 và tại thời điểm viết bài này, rất nhiều dự án vẫn chỉ mới vươn tới Python 3.10, 3.11. Ngay cả thư viện dùng để tô màu code cho bài viết này cũng không hỗ trợ cú pháp này và bị mất màu. Các dự án tại AgriConnect và EasyUni do tôi dẫn dắt thì luôn bám theo Python đi kèm theo bản Ubuntu LTS mới nhất, nên thường được thưởng thức những tính năng mới của Python.
Còn một trở ngại nữa cho việc phát triển hệ thống kiểu cho Python, đó là Python quá mềm dẻo khiến cho phương án gán kiểu, hay việc theo dõi kiểu từ đoạn code này tới đoạn code kia là rất khó khăn, diển hình là các dự án dùng Django. Django và các thư viện phụ trợ cho nó dùng nhiều kĩ thuật "metaprogramming" khiến kiểu dữ liệu bị thay đổi so với khai báo. Ví dụ khi ta định nghĩa model:
from django.db import models
class School(models.Model):
name = models.CharField()
class Student(models.Model):
name = models.CharField()
school = models.ForeignKey(School, related_name='students')
school = School.objects.latest('pk')
school.students
Với công cụ phân tích thông thường, nó sẽ cho rằng biến school
không có thuộc tính students
. Nhưng, do bùa phép "metaprogramming" nên school.students
thực ra tồn tại và sẽ trả về một dãy các đối tượng Student
.
Một ví dụ khác là khi ta dùng DjangoRestFramework
. Ta có một viewset như sau:
class BookViewSet(ModelViewSet[Book]):
permission_classes = [IsAuthenticated]
queryset = Book.objects.all()
def get_queryset(self) -> QuerySet[Book]:
books = super().get_queryset()
# Filter books by logged-in user
return books.filter(owner=self.request.user)
Trong method get_queryset
, khi lấy dữ liệu từ database lên, ta muốn lọc để chỉ lấy Book
thuộc về user đang đăng nhập. Tuy nhiên các công cụ kiểm tra thông thường chỉ nhận diện kiểu của biến self.request.user
là User | AnonymousUser
và sẽ báo lỗi ở dòng books.filter(owner=self.request.user)
, cho rằng biến truyền vào tham số owner
là không hợp lệ, mặc dù thực tế thì view này đã được bảo vệ bởi permission_classes = [IsAuthenticated]
nên request.user
chỉ có kiểu User
và hoàn toàn hợp lệ.
Để giúp các công cụ kiểm tra, ví dụ MyPy, suy luận đúng kiểu trong các trường hợp này, dự án django-stubs đã cung cấp plugin cho MyPy. Tuy nhiên các plugin này viết bằng Python, nên các công cụ viết bằng ngôn ngữ khác, như Pyright, không khai thác được plugin và nhận diện sai tè le.
Không may là những trình soạn thảo nổi tiếng như VS Code, Zed, PyCharm đều có bộ máy autocomplete viết bằng ngôn ngữ khác Python, không khai thác được plugin MyPy nên không thông minh lắm với những dự án dùng Django. Nếu bạn cần một thư viện ORM "sống hòa hợp" với các trình soạn thảo này, nên chọn SQLAlchemy v2.
Vậy là tôi đã giới thiệu qua một vài điểm mạnh điểm yếu về hệ thống kiểu của TypeScript và Python. Hi vọng chúng ta biết cách tận dụng chúng tốt hơn nữa để làm ra những phần mềm chất lượng tốt, hoạt động trơn tru, không hở tí là lăn đùng ra chết và đặc biệt không để hở sườn cho hacker tấn công. Lưu ý rằng gần đây nhiều website bị chèn mã quảng cáo cờ bạc là vì lập trình viên bỏ qua bước "validate dữ liệu".
Cập nhật
Mình mới phát hiện valibot
thư viện validation cho TypeScript, với nhiều tính năng hơn zod
, phù hợp cho frontend hơn vì hỗ trợ cắt tỉa code (tree-shaking, loại bỏ phần không dùng đến để giảm dung lượng code sau khi build), nhưng bù lại API không dễ nhìn bằng zod
.