colinhacks

Activity

Stars and mentions · Last 30 days, Oct 5 – Nov 3

GitHub stars174
Mentions57
05101520Oct 5Oct 19Nov 3
UTC days · repositories colinhacks owns4 repositories

Top repositories

colinhacks’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside colinhacks’s account where colinhacks opened or reviewed pull requests in the past 365 days.

Who noticed colinhacks

Everyone who starred or forked colinhacks’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

59posts
Nov 42 posts
  1. MCP Client Implementation Guide: Transport, Tools, and Errors --- title: "MCP Client Implementation Guide: Transport, Tools, and Errors" published: true tags: ai, typescript, aiengineering, modelcontextprotocol canonical_url: veduis.com cover_image: veduis.com description: "How to build a production Model Context Protocol client in TypeScript. Streamable HTTP, stdio IPC, runtime JSON schema validation, and error recovery." --- Model Context Protocol turned two years old as a specification in late 2025, and by mid-2026 it stopped being a nice-to-have. Anthropic open-sourced it in November 2024, and the ecosystem now counts well over 10,000 community servers on the official registry. If your product talks to LLMs, there is a decent chance a customer will ask whether it can connect to their MCP servers. That means someone on your team has to write a client. I have built MCP clients for two internal tools and one client-facing product at this point, and the parts that eat time are never the happy path. The happy path is fifteen lines with the official SDK. The real work is handling stdio servers that die silently, validating tool arguments before you ship them across the wire, recovering from sessions that drop mid-call, and knowing when to fall back to HTTP instead of spawning a subprocess. This guide covers those four areas with production patterns, not toy examples. !MCP client architecture diagram showing transport layer connecting to tool and resource servers The Transport Decision Comes First MCP defines two transports in the current specification, Streamable HTTP (introduced in the 2025-06-18 spec revision, replacing the older HTTP+SSE transport) and stdio. Picking the wrong one for your deployment model causes more production pain than anything else in the build. Use stdio when the server runs on the same machine or in the same container as your client. This is the model desktop AI applications use: the client spawns the server as a child process, talks JSON-RPC over stdin and stdout, and kills the process when the session ends. The official TypeScript SDK wraps this in StdioClientTransport, and in practice it looks like this: import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; const transport = new StdioClientTransport({ command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"], }); const client = new Client({ name: "my-app", version: "1.0.0" }); await client.connect(transport); Use Streamable HTTP when the server lives elsewhere: a colleague's service, a SaaS offering an MCP endpoint, or your own hosted tool server. The endpoint accepts POST requests carrying JSON-RPC messages and returns responses over a single streamed HTTP response. It also supports resumability via an optional Mcp-Session-Id header, which matters if your client makes long-running tool calls over flaky networks. Here is how to configure a production Streamable HTTP client transport with session persistence and error boundaries: import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHttpClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; interface RemoteMcpConfig { endpoint: string; authToken?: string; sessionId?: string; } export async function createRemoteMcpClient(config: RemoteMcpConfig): Promise<{ client: Client; sessionId: string | null; }> { let activeSessionId: string | null = config.sessionId ?? null; const transport = new StreamableHttpClientTransport( new URL(config.endpoint), { requestInit: { headers: { ...(config.authToken ? { Authorization: `Bearer ${config.authToken}` } : {}), ...(activeSessionId ? { "Mcp-Session-Id": activeSessionId } : {}), }, }, } ); const client = new Client( { name: "enterprise-agent-client", version: "2.1.0" }, { capabilities: { tools: {}, resources: {} } } ); await client.connect(transport); // Capture session id from response headers if the server issued one const responseHeaders = transport.sessionHeaders; if (responseHeaders?.has("mcp-session-id")) { activeSessionId = responseHeaders.get("mcp-session-id")!; } return { client, sessionId: activeSessionId }; } !High-tech developer telemetry comparison console showing two distinct protocol transmission pipelines side by side, local stdio IPC stream vs remote Streamable HTTP session conduit One thing I learned the hard way: stdio servers write logs to stderr by default, and if a server accidentally writes to stdout, your JSON-RPC stream corrupts. When you spawn servers yourself, audit their output during integration testing. A server that prints a startup banner to stdout will break every client that connects to it, and the error message you get ("Parse error on line 1") gives you no hint about the actual cause. Tool Discovery Is a Runtime Problem After connecting, the client calls tools/list to learn what the server offers. Unlike a compiled API client, this list changes at runtime. A filesystem server pointed at a different directory advertises the same tools, but a CRM server exposes different tools per authenticated account. Your client cannot hardcode a tool catalog. The pattern that works: cache the tool list per session, key it by server and credentials, and refresh it when a tool call returns a "tool not found" error or when the server sends a change notification. The SDK returns tool definitions that include a name, description, and an input schema written as JSON Schema. That schema is the contract you must enforce. When we rolled out the VePrompts 2.0 MCP server directory, we saw thousands of community servers with subtle differences in schema quality. Strict client-side runtime validation was the only thing preventing silent execution failures across disparate LLM agents. Validate Arguments Against the Schema, Always Here is the failure mode that convinced me schema validation is non-negotiable. An LLM called a send_email tool with recipient spelled recipent. The server (a lenient JavaScript implementation) accepted it, treated the missing field as undefined, and sent the email to an empty-string fallback address. Nothing crashed. The email just went nowhere, and we found out from a customer two days later. Now every tool call in my clients passes through a JSON Schema validator before it leaves the process. The server should also validate, and the stricter MCP servers written in Rust or Go will reject malformed calls with proper JSON-RPC error codes. But you cannot count on every server being strict, and even strict servers benefit from catching the error before a network round trip. The practical stack is ajv (MIT license, roughly 8 million weekly downloads on npm) or zod with a JSON Schema bridge. Given a tool definition from tools/list, you compile its inputSchema into a validator once per session with ajv.compile(tool.inputSchema) (using new Ajv({ allErrors: true, strict: false })), then check every call and throw with ajv.errorsText(validate.errors) when validation fails. Here is a production TypeScript validator class with per-tool compilation caching and structured error formatting: import Ajv from "ajv"; import type { ValidateFunction } from "ajv"; export class ToolSchemaValidator { private ajv: Ajv; private cache: Map<string, ValidateFunction> = new Map(); constructor() { this.ajv = new Ajv({ allErrors: true, strict: false, coerceTypes: false, }); } public validateCall( toolName: string, schema: Record<string, unknown>, args: unknown ): { valid: boolean; errors?: string } { let validate = this.cache.get(toolName); if (!validate) { validate = this.ajv.compile(schema); this.cache.set(toolName, validate); } const valid = validate(args); if (!valid && validate.errors) { return { valid: false, errors: this.ajv.errorsText(validate.errors, { separator: "; " }), }; } return { valid: true }; } public invalidate(toolName?: string): void { if (toolName) { this.cache.delete(toolName); } else { this.cache.clear(); } } } Two details matter. First, compile once and cache. Recompiling the same schema on every call costs 1 to 5 ms each, which compounds fast when a single user turn triggers ten tool calls. Second, use allErrors: true so you can return every problem to the model in one shot. LLMs are good at fixing argument errors when you give them the complete list of what's wrong, and bad at iterating one error at a time across five round trips. This is the exact principle we follow in spec-driven development for AI agents and when building RAG systems for business knowledge bases: never trust model output at the boundary of a system that takes real actions. Validation is cheap. Explaining a bad action to a customer is not. Error Recovery Patterns That Actually Work MCP uses JSON-RPC 2.0 error codes, and the SDK maps them to a structured error type. In production you will see four failure classes, and each wants a different response. Transport death. The stdio process exits or the HTTP connection resets. My rule: retry once immediately (transient network blips are common), then attempt one full reconnect with a fresh initialize handshake, then surface the failure to the user. Unlimited retries on a dead server just add latency to an already failed request. Log the server's stderr before you kill it; that is where the crash reason lives. Tool execution errors. The server responded but the call failed, codes like -32602 (invalid params) or application-level errors in the result payload. Retry only if the error is clearly transient, such as a rate limit or a 503-equivalent. Never auto-retry validation errors; sending the same bad arguments again will fail identically and burn tokens. Protocol negotiation failures. If your client sends protocol version 2025-06-18 and an older server only speaks 2024-11-05, the initialize handshake fails. Handle this by catching the version error and reconnecting with the server's declared version. The TypeScript SDK exposes client.getServerCapabilities() after connect, so you can gate features (like sampling or elicitation) behind capability checks instead of trying calls blind. Timeouts. Long-running tools exist. A database migration server or a report generator can take minutes. Set a per-tool timeout based on the tool's declared behavior rather than a global 30-second default, and tell the model the timeout exists so it can choose a shorter alternative. The table below summarizes what I ship in every client: | Failure class | First response | Retry policy | |---|---|---| | Transport death | Immediate retry | 1 retry, then reconnect once | | Validation error (-32602) | Return errors to model | Never auto-retry | | Transient server error | Exponential backoff | Max 3 retries, jittered | | Timeout | Cancel and report | Depends on tool, 30s to 10min | | Version mismatch | Reconnect at server version | Once per session | Here is a practical wrapper implementing per-tool timeouts with AbortController, JSON-RPC status discrimination, and exponential backoff: import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; interface ExecutionOptions { timeoutMs?: number; maxRetries?: number; } export async function executeMcpToolWithRecovery( client: Client, toolName: string, args: Record<string, unknown>, options: ExecutionOptions = {} ): Promise<unknown> { const timeoutMs = options.timeoutMs ?? 30000; const maxRetries = options.maxRetries ?? 2; let attempt = 0; while (attempt <= maxRetries) { attempt++; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const result = await client.callTool( { name: toolName, arguments: args }, { signal: controller.signal } ); clearTimeout(timeoutId); return result; } catch (err: unknown) { clearTimeout(timeoutId); // Handle explicit protocol abort if (err instanceof Error && err.name === "AbortError") { throw new Error(`Tool "${toolName}" timed out after ${timeoutMs}ms`); } // Handle JSON-RPC protocol error codes if (err instanceof McpError) { if (err.code === ErrorCode.InvalidParams) { // Hard client validation error: return immediately to LLM without retry throw new Error(`Invalid arguments for tool ${toolName}: ${err.message}`); } if (err.code === ErrorCode.MethodNotFound) { throw new Error(`Tool ${toolName} not found on connected server.`); } } // Handle transient connection drop or server reset if (attempt <= maxRetries) { const backoffMs = Math.min(1000 * Math.pow(2, attempt - 1), 5000); await new Promise((resolve) => setTimeout(resolve, backoffMs)); continue; } throw err; } } } !Precision 3D technical pipeline flowchart showing staged error recovery gates for MCP client failures Security Boundaries Worth Enforcing MCP servers can execute code, read files, and call external APIs with your user's credentials. A client that auto-executes every tool an LLM requests is a remote code execution waiting to happen. Three controls I consider mandatory. First, a tool allowlist per integration. When a user connects their filesystem server, ask which directories it may touch, and reject tool calls outside that scope client-side before the request leaves your process. Second, human confirmation for side-effecting tools. Any tool that writes, deletes, sends, or pays gets a confirmation prompt showing the validated arguments. Third, treat tool descriptions as untrusted input. Research published in early 2025 demonstrated indirect prompt injection through MCP tool descriptions, where a malicious server's description instructs the model to exfiltrate data. Strip instructions from descriptions before presenting them to the model, or at minimum flag servers that are not user-installed. !Glassmorphic AI agent client security perimeter console showing real-time capability inspection shields, tool call allowlist matrix, and verified safe execution status indicators If you are evaluating AI vendors on how they handle these failure modes, my guide to AI hallucination detection for businesses covers the adjacent problem of validating model claims, which pairs well with tool-call validation. When automating software audits and web infrastructure, tools like vComplianceScanner by Veduis demonstrate how automated deterministic engines complement AI pipelines by eliminating LLM guessing on strict regulatory checks. What Most Guides Get Wrong Most MCP tutorials stop at the connect-and-list happy path and imply that the SDK handles everything. It does not. Three things get skipped consistently. Session lifecycle is your job. The SDK opens connections but does not own reconnect logic, session resumption across process restarts, or graceful shutdown that sends the protocol's notifications/cancelled message. Clients that skip cancellation leave servers running tools for requests nobody is waiting on. I have seen a report generator burn 20 minutes of compute after the user closed the tab. stdio is not sandboxing. Spawning npx some-package executes arbitrary code with your process's permissions. The MCP spec defines no permission model, and the official docs are explicit that security is the host application's responsibility. Running third-party stdio servers in a container or a separate user account is table stakes, not paranoia. Tool lists drift. Servers update. A tool that accepted a format argument last month may have renamed it. Clients that cache tool schemas forever start generating calls the server rejects. Refresh your schema cache when you see -32602 errors or when the server signals a list change with the notifications/tools/list_changed notification, which the 2025-06-18 spec supports. Building the Client Loop Around the Model The final piece is wiring tool calls into your LLM loop. The standard pattern: send the tool schemas to the model as function-calling definitions, let the model emit a call, validate it, execute it against the MCP server, and feed the result back as a tool message. Repeat until the model stops calling tools. Keep the number of connected servers per session small (under five in my experience) because tool descriptions consume context window fast. Twenty tools with detailed schemas can eat 15,000 tokens before the user says a word. For a broader view of where MCP fits in a small business AI stack, see the MCP guide for small business owners, which covers the server side and the business case without the implementation detail. Conclusion A production MCP client is a transport manager, a schema validator, and an error recovery engine wrapped around a fifteen-line SDK call. Start with the official TypeScript SDK, pick Streamable HTTP for remote servers and stdio for local ones, validate every tool argument against the server's own JSON Schema before sending it, and build explicit retry policies for each failure class instead of a single global catch. Add a permission boundary for side-effecting tools, and you have a client that survives contact with real servers, real networks, and real users.

  2. TypeScript `globalThis` Typing in 2026: Safely Adding Properties to the Global Scope Without `any` TypeScript globalThis Typing in 2026: Safely Adding Properties to the Global Scope Without any > _This article was written with the assistance of AI, under human supervision and review._ Why globalThis Typing Still Matters in 2026 The global scope remains the path of least resistance for quick hacks. Developers drop variables onto globalThis to share state across modules, expose debug hooks to the browser console, or bridge third-party scripts that expect global names. TypeScript sees these assignments and defaults to any, erasing type safety at the exact boundary where typos and runtime crashes cluster. The broken pattern looks innocent: globalThis.featureFlags = { darkMode: true } compiles without complaint, but every access to globalThis.featureFlags surrenders type checking. When another engineer misspells darkMode as darkmode three modules away, the compiler stays silent and the test suite passes until production traffic hits the undefined code path. !emzvxuokuqzdkmyrzfut.supabase.co Declaration merging solves this. TypeScript permits extending the globalThis interface with new properties, restoring autocomplete and type errors for every global access. The corrected pattern uses an ambient declaration to teach the compiler about the new global without changing runtime behavior. !emzvxuokuqzdkmyrzfut.supabase.co This pattern matters when a codebase has already committed to global state and rewriting it would delay shipping features. The technique also bridges legacy constraints: third-party analytics scripts, browser extensions that inject globals, or Node.js test runners that expose assertion libraries on global. Knowing when each variant (globalThis, window, global) breaks prevents silent failures in cross-environment builds. Key Takeaways - TypeScript allows extending the globalThis interface via declaration merging, restoring full type safety for global properties without runtime overhead. - The declare global block works only in module files; script files can declare directly on interface globalThis but lose module isolation. - The window, global, and globalThis identifiers reference different objects depending on the runtime environment, causing subtle breakage when the wrong one is typed. - Proper cleanup between test suites requires explicit delete statements, as TypeScript type declarations do not remove runtime properties. - Most use cases have better alternatives: environment variable objects, dependency injection, or module-scoped constants avoid global namespace pollution entirely. The Problem: Adding Properties to globalThis Without any A common scenario surfaces when integrating analytics libraries. The vendor provides a snippet that assigns a tracker instance to the global scope: globalThis.analytics = { track: (event, properties) => { // implementation }, identify: (userId, traits) => { // implementation } }; TypeScript infers the type of globalThis.analytics as any because globalThis is a predefined global with a fixed type that does not include an analytics property. Every subsequent access to globalThis.analytics.track bypasses type checking. The engineer who writes globalThis.analytics.trak("click") will not receive an error until runtime. !TypeScript globalThis without proper typing TypeScript globalThis without proper typing Attempting to add a type annotation at the assignment site fails: globalThis.analytics = { track: (event: string, properties: Record<string, unknown>) => {}, identify: (userId: string, traits: Record<string, unknown>) => {} } as AnalyticsClient; // Error: Property 'analytics' does not exist on type 'typeof globalThis' The error reveals the core issue: TypeScript has a preexisting interface definition for globalThis that does not include an analytics member. Adding the property at runtime does not change the compile-time type. !emzvxuokuqzdkmyrzfut.supabase.co The compiler blocks the assignment because it cannot verify that adding arbitrary properties to a global object is safe. The solution requires teaching TypeScript about the new property through declaration merging. Declaration Merging: The Official TypeScript Pattern Declaration merging extends an existing interface by declaring it again with additional members. TypeScript combines all declarations of the same interface into a single definition. For globalThis, this means adding a new interface declaration that specifies the custom property: // types/global.d.ts declare global { interface globalThis { analytics: { track: (event: string, properties: Record<string, unknown>) => void; identify: (userId: string, traits: Record<string, unknown>) => void; }; } } export {}; The declare global block is necessary when the file is a module (contains an import or export statement). The empty export {} at the end forces TypeScript to treat the file as a module, which activates the declare global requirement. Without this, the declaration would apply to the module's local scope instead of the global scope. After adding this declaration, the original assignment compiles without error: globalThis.analytics = { track: (event, properties) => { console.log(event, properties); }, identify: (userId, traits) => { console.log(userId, traits); } }; Type checking now works across the entire codebase. Accessing globalThis.analytics.track provides autocomplete for the method signature, and misspelling the method name triggers a compile error: // Autocomplete suggests: track, identify globalThis.analytics.track("pageview", { path: "/home" }); // Error: Property 'trak' does not exist on type globalThis.analytics.trak("click"); The declaration can reference custom types for cleaner definitions: // types/global.d.ts interface AnalyticsClient { track: (event: string, properties: Record<string, unknown>) => void; identify: (userId: string, traits: Record<string, unknown>) => void; } declare global { interface globalThis { analytics: AnalyticsClient; } } export {}; This pattern works for any property type. Complex nested objects, function signatures, and generic constraints all merge correctly. The TypeScript compiler released version 3.4 with explicit support for typing globalThis, as documented in the TypeScript 3.4 release notes. globalThis vs window vs global: When Each Pattern Breaks The JavaScript language provides three identifiers for the global object: globalThis, window, and global. Each identifier works in specific environments, and typing the wrong one causes silent failures. | Identifier | Browser | Node.js | Web Worker | When it breaks | |---------------|---------|---------|------------|-----------------------------------------------------| | globalThis | ✓ | ✓ | ✓ | Never (ES2020+) | | window | ✓ | ✗ | ✗ | Node.js and Worker environments throw ReferenceError| | global | ✗ | ✓ | ✗ | Browser and Worker environments have no global | Typing window creates code that compiles for browser targets but crashes in Node.js: // types/global.d.ts declare global { interface Window { analytics: AnalyticsClient; } } export {}; // runtime.ts window.analytics = { /* ... */ }; // ReferenceError: window is not defined (in Node.js) !emzvxuokuqzdkmyrzfut.supabase.co The same issue occurs when typing global for Node.js projects that later run in a browser: // types/global.d.ts declare global { namespace NodeJS { interface Global { analytics: AnalyticsClient; } } } export {}; // runtime.ts global.analytics = { /* ... */ }; // ReferenceError: global is not defined (in browser) The globalThis identifier solves this. Introduced in ES2020, globalThis references the global object in all environments. Browsers alias it to window, Node.js aliases it to global, and Web Workers provide it directly. Code that uses globalThis runs unchanged across all targets. TypeScript's type definitions for globalThis live in lib.es2020.d.ts. Projects targeting ES2020 or later automatically include these definitions. For older targets, developers must add "lib": ["ES2020"] to tsconfig.json or include a polyfill that backfills the globalThis identifier at runtime. Module vs Script Context: Why declare global Sometimes Fails TypeScript distinguishes between module files and script files based on the presence of import or export statements. This distinction changes how global declarations work. A script file has no import or export statements. Its declarations apply to the global scope by default: // types/global.d.ts (script file) interface globalThis { analytics: AnalyticsClient; } This works without the declare global wrapper. TypeScript merges the interface directly into the global globalThis type. However, the file lacks module isolation. Any types defined here pollute the global namespace and can conflict with types in other files. A module file contains at least one import or export statement. Module files have their own scope, so declarations do not affect the global scope unless wrapped in declare global: // types/global.d.ts (module file) export {}; interface globalThis { analytics: AnalyticsClient; } // This declaration applies to the module scope, not the global scope The globalThis interface above creates a local type that shadows the global globalThis. Accessing globalThis.analytics in another file still returns any because the global type has not changed. !emzvxuokuqzdkmyrzfut.supabase.co The correct pattern for a module file uses declare global: // types/global.d.ts (module file) declare global { interface globalThis { analytics: AnalyticsClient; } } export {}; The export {} forces the file to be a module while exporting nothing. This preserves module isolation while allowing the declare global block to modify the global scope. Projects that use ES modules (.mts extension or "type": "module" in package.json) default to module context. Adding a global type declaration to such a project without declare global produces no effect, and the compiler offers no warning about the mistake. Real-World Patterns: Environment Variables, Feature Flags, and Runtime Config Three scenarios drive most globalThis typing: environment variables exposed by bundlers, feature flags controlled by remote config, and runtime configuration injected by server-side rendering. Environment Variables Bundlers like Vite expose environment variables through import.meta.env. TypeScript does not know which variables the bundler will inject, so accessing import.meta.env.VITE_API_URL returns string | undefined. Declaring the variables restores specificity: // env.d.ts interface ImportMetaEnv { readonly VITE_API_URL: string; readonly VITE_ENABLE_ANALYTICS: string; } interface ImportMeta { readonly env: ImportMetaEnv; } This pattern extends ImportMeta instead of globalThis, but the principle is identical. The declaration merges into the existing interface, and all accesses to import.meta.env gain type safety. Some projects assign environment variables directly to globalThis for access in non-module code: // runtime-env.ts declare global { interface globalThis { ENV: { API_URL: string; ENABLE_ANALYTICS: boolean; }; } } globalThis.ENV = { API_URL: import.meta.env.VITE_API_URL, ENABLE_ANALYTICS: import.meta.env.VITE_ENABLE_ANALYTICS === "true" }; export {}; !emzvxuokuqzdkmyrzfut.supabase.co This approach centralizes the boolean parsing logic and provides a single typed object for environment configuration. Feature Flags Remote configuration services deliver feature flags as JSON. The consuming application receives an object with boolean properties: // feature-flags.d.ts declare global { interface globalThis { featureFlags: { enableDarkMode: boolean; enableBetaFeatures: boolean; maxUploadSizeMB: number; }; } } export {}; // init.ts fetch("/api/feature-flags") .then(res => res.json()) .then(flags => { globalThis.featureFlags = flags; }); Components that check flags receive autocomplete and type errors: if (globalThis.featureFlags.enableDarkMode) { document.body.classList.add("dark"); } // Error: Property 'enableDrakMode' does not exist if (globalThis.featureFlags.enableDrakMode) { // typo caught at compile time } The risk here is runtime shape mismatch. If the API response changes and removes enableDarkMode, the application will read undefined at runtime despite the TypeScript type claiming a boolean. Adding runtime validation with a library like Zod or writing a manual assertion function mitigates this. !TypeScript globalThis feature flags pattern TypeScript globalThis feature flags pattern Server-Side Rendering Config Frameworks like Next.js inject configuration into the initial HTML payload. The server renders a ` tag that assigns to window`: <script> window.__INITIAL_STATE__ = { user: { id: "123", name: "Alice" }, cart: [] }; </script> The client-side code reads this state before making API calls: // initial-state.d.ts declare global { interface Window { __INITIAL_STATE__: { user: { id: string; name: string }; cart: Array<{ productId: string; quantity: number }>; }; } } export {}; // hydrate.ts const initialState = window.__INITIAL_STATE__; renderApp(initialState); This pattern works only in browser environments. Server-side code that references window.__INITIAL_STATE__ will crash. Conditional checks guard the access: const state = typeof window !== "undefined" ? window.__INITIAL_STATE__ : getDefaultState(); The type system does not enforce this check. Developers must remember to add it, or the build will succeed and production will fail when server-side rendering tries to reference window. Testing and Cleanup: Avoiding Pollution Between Test Suites Global state persists across test files when a test runner executes all files in a single process. A test that assigns globalThis.featureFlags = { enableBeta: true } leaves that property set for subsequent tests. The next test suite that checks if (globalThis.featureFlags.enableBeta) will read the previous value instead of the expected undefined, causing false passes or failures. The TypeScript declaration does not remove runtime properties. Deleting the type does not reset the global: // This does not work: declare global { interface globalThis { featureFlags: undefined; // only affects compile-time type } } Proper cleanup requires explicit delete statements in an afterEach hook: // test-setup.ts afterEach(() => { delete globalThis.featureFlags; delete globalThis.analytics; delete globalThis.ENV; }); !emzvxuokuqzdkmyrzfut.supabase.co TypeScript will still report errors if code accesses the deleted property, because the type declaration remains in scope. Wrapping the delete in a type guard silences this: afterEach(() => { if ("featureFlags" in globalThis) { delete globalThis.featureFlags; } }); Alternatively, setting the property to undefined instead of deleting it preserves the type shape: afterEach(() => { globalThis.featureFlags = undefined as any; }); This approach requires changing the type declaration to allow undefined: declare global { interface globalThis { featureFlags?: { enableDarkMode: boolean; enableBetaFeatures: boolean; }; } } Now every access requires an optional chain or existence check: if (globalThis.featureFlags?.enableDarkMode) { // safe access } The choice between deletion and undefined depends on whether the codebase prefers explicit existence checks or assumes the property is always present during normal operation. When NOT to Use globalThis: Better Alternatives for Most Use Cases Typing globalThis addresses a tactical problem but does not solve the strategic issue of global state. Most applications benefit from alternatives that avoid polluting the global namespace. Module-Scoped Constants Configuration that never changes at runtime belongs in a module: // config.ts export const config = { apiUrl: import.meta.env.VITE_API_URL, enableAnalytics: import.meta.env.VITE_ENABLE_ANALYTICS === "true" }; This provides the same centralized access without touching globalThis. TypeScript infers the exact types, and tree shaking can remove unused properties in production builds. For more patterns around modern TypeScript library configuration, see Create a Modern TypeScript/JavaScript Library for 2023. Dependency Injection Services like analytics clients belong in dependency containers: // services.ts export class Analytics { track(event: string, properties: Record<string, unknown>): void { // implementation } } export const analytics = new Analytics(); // component.ts import { analytics } from "./services"; analytics.track("pageview", { path: "/home" }); This supports testing through mock injection and avoids singleton globals. The pattern scales to complex applications without namespace conflicts. Context Objects React, Vue, and other frameworks provide context mechanisms for shared state: // feature-flags-context.tsx const FeatureFlagsContext = createContext({ enableDarkMode: false, enableBetaFeatures: false }); export function FeatureFlagsProvider({ children }) { const [flags, setFlags] = useState(defaultFlags); useEffect(() => { fetch("/api/feature-flags") .then(res => res.json()) .then(setFlags); }, []); return ( <FeatureFlagsContext.Provider value={flags}> {children} </FeatureFlagsContext.Provider> ); } export const useFeatureFlags = () => useContext(FeatureFlagsContext); Components consume flags through the hook, and the framework handles the scoping. This eliminates global pollution and simplifies testing by allowing test wrappers to provide mock values. When globalThis Is Actually Correct The pattern remains valid for: - Integrating third-party scripts that expect global names (analytics vendors, error trackers) - Exposing debug utilities to the browser console during development - Bridging module code and legacy non-module scripts in a migration period - Polyfilling missing global APIs in older environments If the global property ships to production, the declaration should include comments explaining why alternatives were rejected: declare global { interface globalThis { /** * Segment analytics instance. * Global assignment required by vendor SDK. * See: https://segment.com/docs/connections/sources/catalog/libraries/website/javascript/ */ analytics: SegmentAnalytics; } } This prevents future engineers from assuming the global was added carelessly. For guidance on structuring cross-cutting concerns like correlation IDs in distributed systems, see Correlation IDs in AI Agent Architectures. Frequently Asked Questions Can I declare multiple properties on globalThis in separate files? Yes. TypeScript merges all declarations of the same interface across files. Each file can extend globalThis with different properties, and the compiler combines them into a single type. This works as long as the properties do not conflict. Why does my globalThis declaration not work in a .ts file? The file likely has no import or export statements, making it a script instead of a module. Script files cannot use declare global. Add export {} to force module context, or move the declaration to a .d.ts file. How do I prevent accidental writes to typed global properties? Use readonly in the declaration: declare global { interface globalThis { readonly featureFlags: { enableDarkMode: boolean; }; } } This prevents assignment after initialization: globalThis.featureFlags = newFlags; // Error: Cannot assign to 'featureFlags' Does globalThis typing work with strictNullChecks enabled? Yes. The compiler enforces nullability based on the declared type. If the declaration does not include undefined, TypeScript assumes the property is always present. If runtime code might delete the property, add ? to the declaration or union with undefined. What happens if two libraries declare the same global property with different types? TypeScript reports an error when merging incompatible types. If library A declares globalThis.config: { timeout: number } and library B declares globalThis.config: { retries: number }, the compiler cannot merge them. The developer must declare a combined type that satisfies both libraries or file a bug with the library maintainers. Conclusion Declaration merging makes globalThis properties type-safe without runtime cost. The pattern works across all environments when using globalThis instead of window or global, and it integrates with existing toolchains without configuration changes. The technique fills a narrow gap: code that already relies on global state and cannot migrate to module-scoped alternatives. Most new code should avoid globalThis entirely. Module exports, dependency injection, and framework context APIs provide better isolation and testability. When globals are unavoidable, proper TypeScript declarations prevent the most common failure mode: silent any types that surrender error checking at the exact boundary where runtime bugs cluster. For broader context on modern TypeScript tooling ecosystems, see Biome vs oxlint Comparison 2026.

  3. @r_karotou たしかに、CSSはインジェクションはないですね。 ただ、Prepared Statement 自体はSQLの範疇でもあるので、ちゃんと使えば大丈夫じゃないかと。あとは、 colinhacks/reylo とかも生SQLとして指してる意図でした。

    2 likes · 301 views
  4. Am I the only one who gets sad and angry when stumbling over discussions like in zod#4939 ? If I think AI is helpful, I can ask for myself - no value provided.

    1 like
  5. YouTube · · YouTube description · Eddwan Hallen · colinhacks/reylo

    Open source do GitHub: #15 a #11 | Vol. 18 #shorts Os projetos #15 a #11 do Top 15 open source do GitHub, Vol. 18, 10 segundos para cada um. #15 Aurelio: LosaLosSantos/aurelio-finance #14 diff-tour: init-co-jp/diff-tour #13 Open Intelligent UI: thesysdev/open-intelligent-ui #12 Motion Prompt: showheyohtaki/motion-prompt #11 reylo: colinhacks/reylo Vídeo completo com os 15: youtu.be Oferecimento: Hallen Tech, hallentech.com #shorts #opensource #github

    1 like · 152 views
  6. YouTube · · YouTube description · Eddwan Hallen · colinhacks/reylo

    Mais 15 projetos open source do GitHub que valem o seu tempo | Vol. 18 Mais 15 projetos open source do GitHub que valem o seu tempo, em contagem regressiva e com 10 segundos para cada um: uma alternativa local ao ElevenLabs que clona voz em 646 idiomas, o ComfyUI, o harness de agentes da DeepSeek, o compilador do TypeScript 7 reescrito em Rust, um extrator que não precisa do dobro de espaço no disco e muito mais. 📺 Décimo oitavo volume da série Open source do GitHub. Escolhidos pela análise da lista inteira (estrelas, frequência de commits, último commit, README, documentação, capturas e site), com licença e atividade conferidas em 28/10/2026. Qual desses você vai testar primeiro? Conta nos comentários! ⏱️ CAPÍTULOS E LINKS 0:00 Abertura 0:10 #15 Aurelio: patrimônio, investimentos, ETFs, dinheiro e dívidas num app que roda no seu computador, com um consultor de IA opcional pelo OpenRouter, com a sua chave LosaLosSantos/aurelio-finance 0:20 #14 diff-tour: TUI somente leitura que transforma uma mudança no Git num tour guiado, escrito por Claude Code, Codex, Gemini CLI, OpenCode ou Copilot CLI init-co-jp/diff-tour 0:30 #13 Open Intelligent UI: versão open source da Intelligent UI do ChatGPT, feita com o OpenUI: respostas com mapas, fotos, gráficos e roteiros interativos thesysdev/open-intelligent-ui 0:40 #12 Motion Prompt: 168 nomes de movimentos de motion graphics, câmeras, componentes e mais, cada um com amostra animada e exemplo de pedido para a IA showheyohtaki/motion-prompt 0:50 #11 reylo: importa arquivos .sql direto no TypeScript, com inferência de tipos completa e sem gerar código, do criador do Zod (1,4 mil estrelas) colinhacks/reylo 1:00 #10 prompt2rig: escreva um movimento, um modelo de vídeo encena e um personagem do Mixamo repete em 3D, com mãos e dedos, pronto para o Blender ou o Unity blendi-remade/prompt2rig 1:10 #9 Atticus: assistente de voz para Mac (macOS 26): segure Control, Option e espaço e fale; move janelas, acha arquivos e lê a tela pelo Claude Code no seu plano, ou com um modelo local SaarthurR/atticus-mac 1:20 #8 HatchZip: extrai ZIP, 7z, RAR e tar no lugar, consumindo o arquivo compactado enquanto extrai, então só precisa de espaço para o resultado; Windows, macOS e Linux (2,3 mil estrelas) xevrion/hatchzip 1:30 #7 DeepSeek Harness: o harness de agentes open source da DeepSeek, em que tudo é plugin; ainda em prévia para desenvolvedores (265 mil estrelas) deepseek-ai/deepseek-harness 1:40 #6 Omnara: plataforma open source de agentes gerenciados, com ferramentas, segredos, permissões e streaming, para rodar no seu servidor (3,3 mil estrelas) omnara-ai/omnara 1:50 #5 tsrs: substituto do compilador e do language server do TypeScript 7 escrito em Rust, com bem menos memória e checagem de tipos mais rápida (2,5 mil estrelas) oxc-project/tsrs 2:00 #4 Lemo-Opuscar: 59 estilos de filme feitos em código para o seu agente dirigir curtas, cada um com um filme de exemplo, sem modelo de vídeo (9,1 mil estrelas) lemomo-ai/lemo-opuscar 2:10 #3 ComfyUI: motor de criação com IA por nós: imagem, vídeo, 3D e áudio, rodando no seu computador (144 mil estrelas) Comfy-Org/ComfyUI 2:20 #2 Ponytail: skill que faz o agente de código escrever só o que a tarefa precisa, sem cortar validação, segurança nem acessibilidade (179 mil estrelas) DietrichGebert/ponytail 2:30 Oferecimento Hallen Tech 2:44 #1 VoiceStudio: alternativa local ao ElevenLabs: clonagem e criação de voz, dublagem de vídeo, ditado, transcrição e audiolivros em 646 idiomas, no computador e no celular (93 mil estrelas) debpalash/VoiceStudio 2:54 Encerramento 🧡 Oferecimento: Hallen Tech, liderança de tecnologia sob demanda, software, cloud e segurança para a sua empresa. Site: hallentech.com E-mail: contato@hallentech.com Instagram: instagram.com LinkedIn da Hallen Tech: linkedin.com LinkedIn do Eddwan: linkedin.com Se curtiu, deixa o like, segue o canal e ativa as notificações para a próxima lista. #opensource #github #programacao #ferramentas

    236 likes · 4 comments · 7,546 views
  7. TypeScript の Validation Library 6つを比較してみた!意外だったのは性能じゃなかった話 🤔 みなさん、こんにちは! エビ 🦐 と Type Guard のことばっかり考えているフロントエンドエンジニアの @nyaomaru です!😸 TypeScript で Runtime Validation が必要になると、だいたいこんな話になるよな。 「結局、どの Library を使えばええん?」 Zod? Valibot? ArkType? typia? それとも、自分で Type Guard を書くだけで十分なんちゃう? 僕は is-kit を開発してるので、この境界についてよく考えてる。 そこで最近、こんな疑問が出てきた。 そもそも、同じ問題を解決している Library 同士を比較できてるんやろか? 例えば、欲しいのがこれだけなら、 if (isUser(input)) { input.name; } ほんまに Schema System まで必要なん? でも逆に、 構造化された Validation Error 値の Transformation Parsing Schema Metadata Framework Integration が必要なら、小さな Boolean Guard だけでは足りへんこともある。 そこで今回は「どれが一番ええ Validator なん?」ではなく、ちょっと違う観点で比較してみた。 同じ User の Validation を、以下の7つの方法で実装したで。 手書きの JavaScript Guard + TypeScript の Type Predicate is-kit Runtypes Valibot Zod ArkType typia 6つの OSS と、Library を使わない手書き実装やな。 それぞれを Compile して、同じ Fixture で Test して、Browser Bundle Size を測って、簡単な Steady-state Runtime Benchmark も回した。 で、一番難しかったのは、7つを Compile することじゃなかった。 7つの Validator に、同じ意味の Validation をさせることやった。 例えば「100文字以下の String」という条件ですら、Runtime では違う意味になり得る。 ほんなら、それぞれ何ができて、何を引き換えにしているのか見てこか〜!😺 github.com/nyaomaru 🧪 今回 Validate する User まずは共通の Type から。 type User = { id: string; name: string; age: number; role: "admin" | "user"; tags: string[]; profile: { bio: string; active: boolean; }; }; Runtime で検証する Rule は以下のとおり。 id は String name は UTF-16 Code Unit で1〜100 age は18〜120の整数 role は "admin" または "user" のみ tags は String の Array profile.bio は String profile.active は Boolean Root と profile の余分な Key は拒否 値の Coercion(型変換)はしない 今回の主な Input は、JSON を Decode した普通の Data を想定してる。 ここは重要。 JavaScript には Inherited Property、Sparse Array、Symbol、Getter、Non-enumerable Property みたいな変わった値も作れるからな。 この話は後で戻ってくるで。😹 1. ✍️ 手書き Guard — Guard だけで十分なこともある まずは一番地味な方法。TypeScript の Type Predicate を付けた、手書きの JavaScript Guard や。 Validation Library は使わない。 const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value); const hasExactKeys = ( value: Record<string, unknown>, keys: readonly string[], ): boolean => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); export const isUser = (value: unknown): value is User => { if ( !isRecord(value) || !hasExactKeys(value, ["id", "name", "age", "role", "tags", "profile"]) ) { return false; } if ( !isRecord(value.profile) || !hasExactKeys(value.profile, ["bio", "active"]) ) { return false; } return ( typeof value.id === "string" && typeof value.name === "string" && value.name.length >= 1 && value.name.length <= 100 && typeof value.age === "number" && Number.isInteger(value.age) && value.age >= 18 && value.age <= 120 && (value.role === "admin" || value.role === "user") && Array.isArray(value.tags) && value.tags.every((tag) => typeof tag === "string") && typeof value.profile.bio === "string" && typeof value.profile.active === "boolean" ); }; 使う側はこんな感じ。 declare const input: unknown; if (isUser(input)) { input.name.toUpperCase(); } 一箇所だけ Narrowing したいなら、手書き Guard で十分やと思う。 Dependency も新しい抽象化もいらへん。 でも isUser、isProfile、isRole みたいな Guard がどんどん増えてきたら……。 自分だけの小さな Guard Library を保守し始めてる わけやな。😹 そこで次の選択肢が出てくる。👍 2. 🔧 is-kit — 小さな Guard を再利用したくなったら まさにここが、僕が is-kit を作った理由や。 Narrowing のたびに Schema-first の Workflow を使いたかったわけちゃう。 小さな Guard は小さいまま、きれいに Compose できるようにしたかった。 import { and, arrayOf, isBoolean, isNumber, isString, oneOfValues, predicateToRefine, safeParse, struct, } from "is-kit"; export const isName = and( isString, predicateToRefine<string>( (value) => value.length >= 1 && value.length <= 100, ), ); export const isAge = and( isNumber, predicateToRefine<number>( (value) => Number.isInteger(value) && value >= 18 && value <= 120, ), ); export const isProfile = struct( { bio: isString, active: isBoolean, }, { exact: true }, ); export const isUser = struct( { id: isString, name: isName, age: isAge, role: oneOfValues("admin", "user"), tags: arrayOf(isString), profile: isProfile, }, { exact: true }, ); export const parseUser = (input: unknown) => safeParse(isUser, input); 僕にとって大事なのは、struct() で Object を Validate できること自体じゃない。 組み合わせた部品が、普通の Guard としてそのまま使えること や。 const names = values.filter(isName); if (isAge(value)) { // value is number } すでに Predicate を中心に Code を書いているなら、これは便利やと思う。 ただし、is-kit は Full Schema Library を目指しているわけではない。 safeParse() が返すのは小さな Success / Failure の Result。Field ごとの詳細な Issue、Transformation、Schema Metadata を提供するものではない。 そうした機能や Framework Integration が必要なら、僕なら Schema Library を選ぶで。🔧 それと、Type Level で見つかった点も書いておく。 struct() と arrayOf() は Readonly な構造を Inference する。 だから Runtime の Shape は User と一致していても、readonly string[] は Mutable な string[] に Assign できない。 この違いは Cast で隠さず、Type Test でも明示してるで。🔧 3. 🏃 Runtypes — Predicate ではなく Validator Object Runtypes はもう一段違う考え方や。 単に Boolean を返す Predicate ではなく、一つの Runtime Validator Object に複数の使い方が用意されてる。 import { Array as RtArray, Boolean as RtBoolean, Literal, Number as RtNumber, Object as RtObject, String as RtString, Union, } from "runtypes"; const Name = RtString.withConstraint( (value) => (value.length >= 1 && value.length <= 100) || "name length must be 1..100", ); const Age = RtNumber.withConstraint( (value) => (Number.isInteger(value) && value >= 18 && value <= 120) || "age must be an integer in 18..120", ); export const UserSchema = RtObject({ id: RtString, name: Name, age: Age, role: Union(Literal("admin"), Literal("user")), tags: RtArray(RtString), profile: RtObject({ bio: RtString, active: RtBoolean, }).exact(), }).exact(); export const isUser = (input: unknown) => UserSchema.guard(input); export const checkUser = (input: unknown) => UserSchema.check(input); export const parseUser = (input: unknown) => UserSchema.parse(input); export const inspectUser = (input: unknown) => UserSchema.inspect(input); 普通の Predicate と違って、同じ Validator に複数の使い方がある。 guard() で Narrowing check() で失敗時に Throw parse() で Parsing inspect() で構造化された Failure Data を取得 ただし check() と parse() は同じ意味ではない。Parser の挙動によって Object の扱いが変わる場合もある。 単純な Boolean Guard とは違う Runtime の仕組みやな。🏃 4. 🤖 Valibot — Modular な API の Schema Validation Valibot からは、いよいよ Schema Library らしくなってくる。 import * as v from "valibot"; export const UserSchema = v.strictObject({ id: v.string(), name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)), age: v.pipe(v.number(), v.integer(), v.minValue(18), v.maxValue(120)), role: v.picklist(["admin", "user"]), tags: v.array(v.string()), profile: v.strictObject({ bio: v.string(), active: v.boolean(), }), }); export const isUser = (input: unknown) => v.is(UserSchema, input); export const parseUser = (input: unknown) => v.safeParse(UserSchema, input); Valibot は Schema Validation、構造化された Issue、Transformation Pipeline を、Modular な Function-based API で組み合わせられる。 この設計は Tree Shaking とも相性がええ。 さらに v.is() は Type Guard として使えるで。🤖 ただし v.is() 自体は詳細な Issue を返さず、Parsing のように Transformation を適用するわけでもない。 5. 💎 Zod — Schema が Ecosystem の一部になるとき 続いて Zod。 import * as z from "zod"; export const UserSchema = z.strictObject({ id: z.string(), name: z .string() .refine( (value) => value.length >= 1 && value.length <= 100, "name must contain 1..100 UTF-16 code units", ), age: z.int().min(18).max(120), role: z.enum(["admin", "user"]), tags: z.array(z.string()), profile: z.strictObject({ bio: z.string(), active: z.boolean(), }), }); export type User = z.output<typeof UserSchema>; export const parseUser = (input: unknown) => UserSchema.safeParse(input); // This schema has no transforms, so its output type is safe for the input too. export const isUser = (input: unknown): input is User => parseUser(input).success; Zod に組み込みの is() Method はないけど、この Adapter は元の input を Narrowing できる。 今回の Schema には Transform がないからやな。 Transform を含む Schema なら、元の Input と Output の Type が違う可能性があるので、safeParse().data を Typed Value として使うほうが安全。 ここは混同したらアカンところ。 さて、Zod の魅力は User を Validate できること自体ではない。 それなら今回のどの実装でもできる。 大きいのは Schema を取り巻く Ecosystem や。 Form Integration API Tooling Schema Conversion Metadata Codec OpenAPI 関連 Tooling Framework Integration Schema が Application Infrastructure の一部になっているなら、こうした Ecosystem は強い選択理由になる。 String Length で気になったこと 今回、これは使わなかった。 z.string().min(1).max(100); なんでかって? 今回の Contract は、 1〜100 UTF-16 Code Unit やから。 Zod の組み込み String Length Check は Unicode Code Point を数えるので、Contract に合わせるため .refine() と JavaScript の string.length を使った。 後で出てくる Emoji の Fixture で、違いがはっきりするで。😹 Bundle Size はどうなん? 「Valibot は小さいけど、Zod は大きい」 そう単純にも言えへん。Zod には Functional で Tree-shakable な Zod Mini もある。 後半の Bundle Size は 通常版 Zod の結果で、Zod Mini の測定ではないで。 また Zod は z.compile() で Runtime の Validation Logic を最適化できるけど、今回は有効にしていない。 これは typia の Build 時の TypeScript Transform とは別の仕組みや。💎 6. 🚢 ArkType — Runtime Schema が TypeScript っぽかったら? ArkType は Schema の書き方がかなり違う。 import { type } from "arktype"; export const UserSchema = type({ id: "string", name: "0 < string <= 100", age: "18 <= number.integer <= 120", role: "'admin' | 'user'", tags: "string[]", profile: { bio: "string", active: "boolean", }, }).onDeepUndeclaredKey("reject"); export const isUser = (input: unknown) => UserSchema.allows(input); export const parseUser = (input: unknown) => UserSchema(input); export const ArkErrors = type.errors; この比較、結構好きやねんな。 z.int().min(18).max(120); と、 "18 <= number.integer <= 120"; どっちが絶対に優れている、という話ではない。 同じ Runtime Contract を表現するための、違う書き方や。 TypeScript っぽい式が自然に読める Team なら、ArkType はかなり読みやすいと思う。 allows() は Type Guard として使えるし、Schema を直接呼ぶと Validated Output か Error Collection を取得できる。 もう一つ面白いのは、new Function が使える Environment なら Runtime で Traversal Logic を Precompile できること。 つまり ArkType も、すでに最適化された Validation Logic を作っている。 でも typia と同じ Architecture ではない。 ArkType は Runtime の Type Representation から始まる。 typia は Build 時の TypeScript Type から始まるんや。🚢 7. 🚀 typia — Build 時に Validator を生成する 今回の中で、typia は特に毛色が違う。 import typia, { type tags } from "typia"; type Utf16Name = tags.TagBase<{ kind: "utf16Name"; target: "string"; value: undefined; validate: "$input.length >= 1 && $input.length <= 100"; }>; export interface User { id: string; name: string & Utf16Name; age: number & tags.Type<"uint32"> & tags.Minimum<18> & tags.Maximum<120>; role: "admin" | "user"; tags: string[]; profile: { bio: string; active: boolean; }; } export const isUser = typia.createEquals<User>(); export const validateUser = typia.createValidateEquals<User>(); typia は TypeScript の Type と Tag を、Build 時に専用の Validation Code に変換する。Runtime Schema Object をその場で解釈する方式とは違う。🚀 ただし、Trade-off もある。 Build Integration が必要。 今回の公式 Setup は TypeScript 7 と ttsc / typia の Transform Integration を使う。 普通の tsc だけでは Transform は走らない。 僕の検証環境でも、最初の typia Build では Go の Source Plugin を Compile する必要があり、数分かかった。 この Setup Cost は Runtime Benchmark には入っていないけど、採用を決めるときには無視できへんよな。 それから、組み込みの MinLength / MaxLength は今回の UTF-16 Contract と一致しなかった。 だから name には Custom Tag を使ってる。 またしても「同じような Rule に見えるのに、実際の意味が違う」という話や。🚀 😹 えっ、String Length って同じじゃないん? 最初に「えっ?」となったのがこの Test。 "😀".repeat(51).length; // 102 51 Unicode Code Point なのに、102 UTF-16 Code Unit になる。 今回の Contract は最大100 UTF-16 Code Unit。だから Reject されるべきや。 実際、最初から Reject したのは手書き Guard、is-kit、Runtypes、Valibot、ArkType。 一方、最初の Zod と typia の Sample は Accept した。なんでやねん!😹 組み込みの Length Constraint が Unicode Code Point を数えていたからや。 別にどっちが間違いというわけではない。 違う Runtime Contract を表している というだけ。 さらに、人間から見た「1文字」とも必ずしも一致しない。Grapheme Cluster まで考えたら Unicode はもっと面白いことになる。😹 だから、最終的には Zod を .refine() に、typia を Custom Tag に変更した。 今回の比較で、個人的に一番大きかった発見がこれ。 同じように見える Schema が、同じ Runtime Contract を表しているとは限らない。 で、結局何を比較してるん? ここまで見ると、この7つは単なる交換可能な Library には見えへんよな。 そもそも Runtime Model が違う。 まず表で整理してみよう。 Approach Source of truth Natural success API Structured failures Transformation model Compilation / build requirement Handwritten guard Type + handwritten check Predicate Manual Manual None is-kit Composed guards Predicate / safeParse Basic valid/invalid result Guard-oriented None Runtypes Runtype object guard / check / parse Yes Parsers None Valibot Schema functions is / safeParse Yes Schema pipelines None Zod Schema objects parse / safeParse Yes Transforms / codecs No build transform; optional runtime z.compile() ArkType Runtime type expressions allows / invocation Yes Morphs No build transform; runtime precompilation when available typia TypeScript types and tags Generated predicates / validation results With validation APIs Build-generated functions Build transform required だから、これらを「上位互換への進化段階」みたいに考えるのは違うと思うようになった。 Schema Library は「より優れた Type Guard」とは限らない。 Generated Validator も「より優れた Schema」とは限らない。 問題が重なる部分はあるけど、解決している Layer が違うんやな。 ✅ 実際に何が Pass したん? 同じ35個の JSON Fixture で7つの実装を検証した。 35 fixtures × 7 validators = 245 checks 245個、すべて Pass! ✅ 境界値、Emoji の Length、間違った型、Missing Field、余分な Key などを確認してる。Harness では Type Narrowing、Input Mutation、Build、Bundle 後の動作も検証した。 再現してみたい人はこちら。😺 Harness、Lockfile、Fixture、Probe、Benchmark の結果は、固定 Commit の typescript-validator-comparison に公開してるで。 git clone nyaomaru/typescript-validator-comparison cd typescript-validator-comparison git checkout --detach b26350d01fd18cb77d55a95e2f3b112657f0d305 corepack pnpm install --frozen-lockfile corepack pnpm verify 今回の JSON に相当する Contract では7つの実装が一致した、という結果や。あらゆる JavaScript Value に対する完全な一致を意味するわけではないで。 🧩 もう一つの驚き。「Exact」って本当に Exact なん? 普通の JSON に収まらない Value も Test してみた。 表に出す4つの Probe は、この Code に対応してる。 const sparseTags = { ...valid, tags: new Array(2) }; const inheritedFields = Object.create(valid); const extraSymbol = { ...valid, [Symbol("extra")]: true }; const extraUndefined = { ...valid, extra: undefined }; Repository では、このほかに Non-enumerable Property や Throw する Getter を含む、さらに9つの Probe を実行している。 この4つの結果はこんな感じ。 Probe Native is-kit Runtypes Valibot Zod ArkType typia Sparse tags array Accept Reject Accept Reject Reject Reject Accept All fields inherited Reject Reject Reject Accept Accept Accept Accept Extra symbol property Accept Accept Reject Accept Accept Reject Accept Extra key with value undefined Reject Reject Reject Reject Reject Reject Accept さらに、Test した余分な Non-enumerable Property は7つすべてで Accept された。 そして Throw する Getter を読み取るケースでは、7つとも Throw した。 どれかの Library が壊れている、という話ではない。 「Exact Object Validation」には、対象範囲の定義が必要 ということや。 手書き Guard の Object.keys() は Symbol や Non-enumerable Key を見ないし、Array.every() は Sparse Array の Hole を Skip する。 JSON Data なら合理的でも、あらゆる JavaScript Object に共通する Rule ではないんやな。😺 📦 Bundle Size さて、みんな気になる Bundle Size や!😺 Browser 向けの各 Entry は validate だけを Export している。 設定はすべて同じ esbuild の条件に揃えた。 Browser Platform ESM ES2022 Minify Tree Shaking Legal Comment を削除 Raw、gzip、Brotli の Size を測った結果がこれ。 Validator Raw bytes gzip bytes Brotli bytes handwritten guard 588 337 295 is-kit 3,421 1,530 1,423 runtypes 16,667 5,188 4,657 valibot 5,722 1,853 1,675 zod 89,888 26,212 23,331 arktype 155,549 47,603 41,956 typia 772 403 339 結果を見る前に、3つだけ注意してな。👇 Standalone Cost: 独立した Bundle の Size。既存 Application に Schema を1つ追加する Cost とは違う。 通常版 Zod: Zod Mini は測っていない。 Generated Code: typia の小さな Bundle にも生成 Code と Helper は含まれる。 それでも、Architecture の違いが表れているのは面白いな。😺 ⚡ Runtime Performance 同じ Contract で、簡単な Steady-state Benchmark も回してみた。 Case は正常な User、間違った id、間違った profile.active の3つ。 各 Library を別々の Node Process で実行し、20,000 Call の Warmup 後に、50,000 Call × 9 Sample を測定した。Schema の生成は測定 Loop の外や。 結果は 1 Call あたりの Median(ns)。括弧内は Min〜Max。 これは Microbenchmark なので、V8 の最適化や API の処理内容の違いに注意してな。 Validator Valid (ns/call) Wrong id (ns/call) Wrong profile.active (ns/call) handwritten guard 79.9 (77.3–114.1) 73.6 (72.1–84.9) 80.8 (79.0–96.3) is-kit 232.0 (225.7–257.5) 34.5 (33.1–38.9) 210.3 (203.6–218.1) runtypes 5804.0 (5425.5–6298.0) 9221.8 (8443.2–11063.2) 10101.2 (9287.6–10503.5) valibot 951.2 (425.2–1782.7) 200.2 (96.1–422.3) 1112.1 (648.4–1538.1) zod 612.6 (351.3–1203.5) 1491.6 (862.9–2824.6) 2208.5 (1592.4–4757.4) arktype 291.5 (278.1–614.4) 9.6 (6.6–17.7) 12.0 (11.3–37.6) typia 21.9 (20.5–46.1) 2.0 (1.9–6.4) 11.5 (10.8–17.2) ただし、これを 「typia がどんな状況でも最速!」 と読むのは違うで。😹 同じ小さな Object を繰り返しているので、2.0 ns のような値を実際の Application にそのまま一般化はできへん。 それでも、面白い傾向はあった。👇 🚀 typia はこの Steady-state Test で Overhead が小さかった。 🚢 ArkType は Invalid Input の処理が特に速かった。 🔧 is-kit は間違った id を早い段階で Reject した。 🏃 Runtypes は今回の Loop では比較的 Cost が高かった。 Architecture と Failure Path の両方が大事、ということやな。 🤔 ほな、結局どれを使う? ここまで検証した結果、僕の選び方は結構シンプルになった。 やりたいこと 候補 一箇所だけ Type Check ✍️ 手書き Guard 再利用できる Guard を Compose 🔧 is-kit Rich な Validator API 🏃 Runtypes Modular な Schema Validation 🤖 Valibot Schema の Ecosystem や Integration 💎 Zod TypeScript っぽい Schema Syntax 🚢 ArkType Build 時に Type から Validator を生成 🚀 typia 万人向けの勝者はおらへん。 😺 今回一番驚いたのは、Bundle Size や Speed ではなく、7つの Validator が、ちょっとずつ違う Runtime Contract を表現し得る ということ。 だから Library を選ぶ前に考えたい。 何を Validate して、結果として何が必要なん? 一番小さくて速いものが、その Boundary にとって一番合うとは限らへん。 みんななら、どれを使う?🤔 最後まで読んでくれてありがとう〜!🤲🦐

  8. I Compared 6 TypeScript Validation Libraries. Here's What Surprised Me 🤔 Hoi hoi! 👋   I'm nyaomaru, a frontend engineer and OSS developer working mostly with TypeScript. When we need runtime validation in TypeScript, the conversation often turns into this 👇 > "Which library should I use?" - Zod? - Valibot? - ArkType? - typia? Or maybe, just write a type guard? I maintain is-kit, so I think about this boundary quite a lot. And recently I started wondering > Are we comparing libraries that are actually solving the same problem? For example, if all I want is this if (isUser(input)) { input.name; } do I really need a schema system? But if I want: - structured validation errors - transformations - parsing - schema metadata - framework integrations then a small boolean guard is probably not enough either. So instead of asking "Which validator is best?", I tried a different comparison. I implemented the same User validation with: - a handwritten guard with a TypeScript predicate - is-kit - Runtypes - Valibot - Zod - ArkType - typia Then I compiled, tested them against the same fixtures, measured standalone browser bundle size, and ran a small steady-state runtime benchmark. The hardest part was not making seven validators compile. It was making seven validators mean the same thing. Even “a string with at most 100 characters” can describe different runtime contracts. So, let's look at what each tool is actually buying us. 😺 !TypeScript Validation Battle --- 🧪 The User we will validate Here is the shared type. type User = { id: string; name: string; age: number; role: "admin" | "user"; tags: string[]; profile: { bio: string; active: boolean; }; }; And the runtime rules are: - id must be a string - name must contain 1–100 UTF-16 code units - age must be an integer from 18 to 120 - role must be exactly "admin" or "user" - tags must be an array of strings - profile.bio must be a string - profile.active must be a boolean - extra keys are rejected at the root and inside profile - values are not coerced For this comparison, the main input domain is ordinary JSON-decoded data. That matters because JavaScript can also give us strange things like inherited properties, sparse arrays, symbols, getters, and non-enumerable keys. We'll come back to those later. --- 1. ✍️ Handwritten Guard — Sometimes a Guard Is Enough Let's start with the boring option, a handwritten JavaScript guard with a TypeScript predicate. No validation library. const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value); const hasExactKeys = ( value: Record<string, unknown>, keys: readonly string[], ): boolean => Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); export const isUser = (value: unknown): value is User => { if ( !isRecord(value) || !hasExactKeys(value, ["id", "name", "age", "role", "tags", "profile"]) ) { return false; } if ( !isRecord(value.profile) || !hasExactKeys(value.profile, ["bio", "active"]) ) { return false; } return ( typeof value.id === "string" && typeof value.name === "string" && value.name.length >= 1 && value.name.length <= 100 && typeof value.age === "number" && Number.isInteger(value.age) && value.age >= 18 && value.age <= 120 && (value.role === "admin" || value.role === "user") && Array.isArray(value.tags) && value.tags.every((tag) => typeof tag === "string") && typeof value.profile.bio === "string" && typeof value.profile.active === "boolean" ); }; And then declare const input: unknown; if (isUser(input)) { input.name.toUpperCase(); } This is still very good. For one local boundary, a handwritten type guard has a huge advantage > There is no extra model to learn. It is just JavaScript runtime checks plus a TypeScript predicate. But the cost starts to appear when your codebase grows this isUser; isProfile; isRole; isNonEmptyString; isAdultAge; isStringArray; isApiResponse; isConfig; isFeatureFlag; Now you are not writing "one guard" anymore. You are maintaining your own tiny guard library. And this is where the next option starts to make sense. 👍 --- 2. 🔧 is-kit — When Small Guards Become Reusable Building Blocks This is the problem I built is-kit around. I didn't want a schema-first workflow for every narrowing problem. I wanted small guards that stay small, but compose cleanly. import { and, arrayOf, isBoolean, isNumber, isString, oneOfValues, predicateToRefine, safeParse, struct, } from "is-kit"; export const isName = and( isString, predicateToRefine<string>( (value) => value.length >= 1 && value.length <= 100, ), ); export const isAge = and( isNumber, predicateToRefine<number>( (value) => Number.isInteger(value) && value >= 18 && value <= 120, ), ); export const isProfile = struct( { bio: isString, active: isBoolean, }, { exact: true }, ); export const isUser = struct( { id: isString, name: isName, age: isAge, role: oneOfValues("admin", "user"), tags: arrayOf(isString), profile: isProfile, }, { exact: true }, ); export const parseUser = (input: unknown) => safeParse(isUser, input); The important part is that the pieces remain normal guards const names = values.filter(isName); if (isAge(value)) { // value is number } That is useful when your codebase already thinks in terms of predicates. And this is also where I would draw the line. safeParse() gives a small success/failure result around a guard. It does not turn is-kit into: - a field-level issue system - a transformation pipeline - a schema metadata system - an OpenAPI ecosystem - a form integration layer If those are the actual requirements, I would rather use a schema library than try to make a guard toolkit become one. One type-level detail also showed up in the verification, struct() and arrayOf() infer readonly structures. So the runtime shape matches our User, but readonly string[] is not assignable to mutable string[]. That difference is real, and the type tests make it explicit instead of hiding it with a cast. 🔧 --- 3. 🏃 Runtypes — A Validator Object, Not Just a Predicate Runtypes moves one level further. Instead of just a boolean predicate, you get a runtime validator object with several ways to consume it. import { Array as RtArray, Boolean as RtBoolean, Literal, Number as RtNumber, Object as RtObject, String as RtString, Union, } from "runtypes"; const Name = RtString.withConstraint( (value) => (value.length >= 1 && value.length <= 100) || "name length must be 1..100", ); const Age = RtNumber.withConstraint( (value) => (Number.isInteger(value) && value >= 18 && value <= 120) || "age must be an integer in 18..120", ); export const UserSchema = RtObject({ id: RtString, name: Name, age: Age, role: Union(Literal("admin"), Literal("user")), tags: RtArray(RtString), profile: RtObject({ bio: RtString, active: RtBoolean, }).exact(), }).exact(); export const isUser = (input: unknown) => UserSchema.guard(input); export const checkUser = (input: unknown) => UserSchema.check(input); export const parseUser = (input: unknown) => UserSchema.parse(input); export const inspectUser = (input: unknown) => UserSchema.inspect(input); This feels different from a guard toolkit. A predicate mainly answers > Is this value a User? A Runtype can also answer that question, but the same object can: - narrow with guard - throw with check - parse with parse - expose structured failure data Also, check() and parse() should not be treated as synonyms. Runtypes supports parser behavior, and object handling can differ between validation and parsing contexts. That is exactly the kind of runtime machinery you do not have in a plain predicate. 🏃 --- 4. 🤖 Valibot — Schema Validation With a Modular API Valibot is where the comparison starts looking more "schema-like". import * as v from "valibot"; export const UserSchema = v.strictObject({ id: v.string(), name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)), age: v.pipe(v.number(), v.integer(), v.minValue(18), v.maxValue(120)), role: v.picklist(["admin", "user"]), tags: v.array(v.string()), profile: v.strictObject({ bio: v.string(), active: v.boolean(), }), }); export const isUser = (input: unknown) => v.is(UserSchema, input); export const parseUser = (input: unknown) => v.safeParse(UserSchema, input); This gives us: - schema validation - type inference - structured issues - parsing APIs - transformation pipelines But the API is still function-based and modular. That is a big part of Valibot's design. Instead of every capability living as methods on large schema objects, you compose independent functions. That makes tree shaking a first-class part of the normal API. And unlike a pure schema-only mental model, Valibot also exposes v.is() as a type guard. So schema validation and ordinary TypeScript narrowing are not mutually exclusive. One caveat: v.is() is the guard path. It does not expose validation issues or apply transformations like parsing does. 🤖 --- 5. 💎 Zod — When the Schema Is Part of a Bigger Ecosystem Now Zod. import * as z from "zod"; export const UserSchema = z.strictObject({ id: z.string(), name: z .string() .refine( (value) => value.length >= 1 && value.length <= 100, "name must contain 1..100 UTF-16 code units", ), age: z.int().min(18).max(120), role: z.enum(["admin", "user"]), tags: z.array(z.string()), profile: z.strictObject({ bio: z.string(), active: z.boolean(), }), }); export type User = z.output<typeof UserSchema>; export const parseUser = (input: unknown) => UserSchema.safeParse(input); // This schema has no transforms, so its output type is safe for the input too. export const isUser = (input: unknown): input is User => parseUser(input).success; Zod does not expose a built-in is() method, but this adapter can narrow input because this schema has no transforms. With a transforming schema, safeParse().data is the safe typed value: the original input and output types may differ. Zod is not interesting because it can validate a User. All of these libraries can do that. The real value is everything around the schema: - form integrations - API tooling - schema conversion - metadata - codecs - OpenAPI-related tooling - framework integrations If your schema is already becoming part of application infrastructure, that ecosystem matters. 💎 There is one important detail in this sample though. I did not use this z.string().min(1).max(100); Why? Because our contract says > 1–100 UTF-16 code units Zod's built-in string length checks use Unicode code points, so this sample uses .refine() with JavaScript's string.length to preserve the stated UTF-16 contract. The shared emoji fixture demonstrates why below. What about bundle size? It is too easy to write > Valibot is small, Zod is large. That is not a good 2026 comparison anymore. Zod also has Zod Mini, which uses a more functional, tree-shakable API. The bundle table later in this article measures regular Zod, not Zod Mini. So it should not be read as Valibot versus the smallest possible Zod setup. Zod can also compile optimized validation logic at runtime with z.compile() and new Function. I did not enable it here. That makes this distinction more useful than the broad label “generated validation” runtime schema compilation vs build-time generation from TypeScript types That distinction becomes important when we get to typia. --- 6. 🚢 ArkType — What If the Runtime Schema Looked Like TypeScript? ArkType has a very different authoring model. import { type } from "arktype"; export const UserSchema = type({ id: "string", name: "0 < string <= 100", age: "18 <= number.integer <= 120", role: "'admin' | 'user'", tags: "string[]", profile: { bio: "string", active: "boolean", }, }).onDeepUndeclaredKey("reject"); export const isUser = (input: unknown) => UserSchema.allows(input); export const parseUser = (input: unknown) => UserSchema(input); export const ArkErrors = type.errors; I like this comparison z.int().min(18).max(120); vs "18 <= number.integer <= 120"; Neither is "better". They are different ways to express the runtime contract. If your team already thinks naturally in TypeScript-like expressions, ArkType is very readable. allows() also works as a type guard. And calling the schema directly gives you validated output or an error collection. Another interesting part: ArkType can precompile optimized traversal logic at runtime when the environment allows new Function. So ArkType is already doing specialization. 🚢 But it is still not the same architecture as typia. ArkType starts from a runtime type representation. typia starts from TypeScript types during the build. --- 7. 🚀 typia — Generate the Validator During the Build typia is the outlier in this comparison. import typia, { type tags } from "typia"; type Utf16Name = tags.TagBase<{ kind: "utf16Name"; target: "string"; value: undefined; validate: "$input.length >= 1 && $input.length <= 100"; }>; export interface User { id: string; name: string & Utf16Name; age: number & tags.Type<"uint32"> & tags.Minimum<18> & tags.Maximum<120>; role: "admin" | "user"; tags: string[]; profile: { bio: string; active: boolean; }; } export const isUser = typia.createEquals<User>(); export const validateUser = typia.createValidateEquals<User>(); This is not a runtime schema object. The source of truth is the TypeScript type plus typia's tags. Then typia's build transform turns typia.createEquals<User>(); into specialized validation code. That means there is no general runtime schema interpreter walking a User schema object. But there is a trade-off > You need build integration. The current official setup uses TypeScript 7 and ttsc / typia's transform integration. Stock tsc alone does not run the transform. In my verification workspace, the first typia build also had to compile a Go source plugin and took several minutes. That setup cost is not part of the runtime benchmark, but it is absolutely part of the adoption decision. Also, typia's built-in MinLength / MaxLength did not match our UTF-16 contract. So I used a custom tag for name. Again, same-looking validation rule, different semantics. 🚀 --- 😹 Wait, Even "String Length" Was Different? This was the first test that made me stop. "😀".repeat(51).length; // 102 JavaScript's string.length counts UTF-16 code units. So with our contract maximum = 100 UTF-16 code units this should fail. And it did fail in: - handwritten guard - is-kit - Runtypes - Valibot - ArkType But the original Zod and typia samples accepted it. Why? Because their built-in length constraints count Unicode code points instead. For this string 51 emoji = 51 code points = 102 UTF-16 code units Neither behavior is wrong. They are just different contracts. And neither one necessarily matches "what a human sees" either, because grapheme clusters make Unicode even more fun. 😹 So I changed the Zod sample to .refine() and typia to a custom tag. That was probably the most useful lesson in the whole comparison 👇 > Equivalent-looking schemas are not automatically equivalent runtime contracts. --- So What Are We Actually Comparing? At this point, these no longer look like seven interchangeable libraries. They are different runtime models. | Approach | Source of truth           | Natural success API                       | Structured failures       | Transformation model       | Compilation / build requirement                           | | --------- | -------------------------- | ------------------------------------------ | --------------------------- | -------------------------- | ---------------------------------------------------------- | | Handwritten guard | Type + handwritten check   | Predicate                                 | Manual                     | Manual                     | None                                                       | | is-kit   | Composed guards           | Predicate / safeParse                   | Basic valid/invalid result | Guard-oriented             | None                                                       | | Runtypes | Runtype object             | guard / check / parse               | Yes                         | Parsers                   | None                                                       | | Valibot   | Schema functions           | is / safeParse                         | Yes                         | Schema pipelines           | None                                                       | | Zod       | Schema objects             | parse / safeParse                     | Yes                         | Transforms / codecs       | No build transform; optional runtime z.compile()         | | ArkType   | Runtime type expressions   | allows / invocation                     | Yes                         | Morphs                     | No build transform; runtime precompilation when available | | typia     | TypeScript types and tags | Generated predicates / validation results | With validation APIs       | Build-generated functions | Build transform required                               | This is why I no longer like thinking about them as a simple upgrade ladder. A schema library is not "a better type guard". A generated validator is not "a better schema". They solve overlapping problems at different layers. --- ✅ What Actually Passed? The complete harness, lockfile, fixtures, probes, and generated result files live in typescript-validator-comparison. The article links to that immutable commit, rather than to a moving branch. To reproduce the recorded snapshot: git clone https://github.com/nyaomaru/typescript-validator-comparison.git cd typescript-validator-comparison git checkout --detach b26350d01fd18cb77d55a95e2f3b112657f0d305 corepack pnpm install --frozen-lockfile corepack pnpm verify pnpm verify runs lint, strict type checking, fixtures, bundle measurement, benchmarks, JavaScript-only probes, and report generation. The result tables below are that run's saved snapshot, reruns on another machine will naturally produce different timing numbers. I pinned the versions and ran the same fixture matrix against all seven implementations. Environment Date:       2026-10-23 Node:       24.22.1 TypeScript: 7.0.2 pnpm:       11.22.0 ttsc:       0.31.3 tsbuild:    0.28.2 Packages | Package   | Version | | --------- | -------- | | is-kit   | 1.15.3   | | runtypes | 7.0.5   | | valibot   | 1.5.0   | | zod       | 4.6.5   | | arktype   | 2.2.8   | | typia     | 15.1.0   | The shared matrix contains 35 JSON fixtures. That includes: - valid users - age boundaries - name boundaries - emoji length cases - wrong primitive types - missing fields - invalid roles - invalid array elements - extra root keys - extra nested keys - non-object roots All seven matched the expected result. 35 fixtures × 7 validators = 245 boolean checks All 245 passed. The harness also checks input mutation, richer APIs, strict TypeScript compilation, lint, builds, narrowing, and bundled output behavior. So the implementations agree for the JSON-shaped contract in this article—not necessarily for every JavaScript value. --- 🧩 One More Surprise: "Exact" Is Not Always Exact I also tested values outside the normal JSON-shaped input domain. The four probes in the table mean exactly this: const sparseTags = { ...valid, tags: new Array(2) }; const inheritedFields = Object.create(valid); const extraSymbol = { ...valid, [Symbol("extra")]: true }; const extraUndefined = { ...valid, extra: undefined }; The repository runs nine more probes, including non-enumerable properties and a throwing getter. Here is what happened for these four | Probe                             | Native | is-kit | Runtypes | Valibot | Zod     | ArkType | typia   | | --------------------------------- | ------ | ------ | -------- | -------- | ------- | -------- | ------- | | Sparse tags array               | Accept | Reject | Accept   | Reject   | Reject | Reject   | Accept | | All fields inherited             | Reject | Reject | Reject   | Accept   | Accept | Accept   | Accept | | Extra symbol property             | Accept | Accept | Reject   | Accept   | Accept | Reject   | Accept | | Extra key with value undefined | Reject | Reject | Reject   | Reject   | Reject | Reject   | Accept | All seven accepted the extra non-enumerable property I tested. All seven threw when reading a getter that throws. This does not mean one library is "correct" and the others are broken. It means > "Exact object validation" needs a scope. For example, my native guard uses Object.keys(value); That only sees enumerable string keys. And this value.tags.every(...) skips sparse array holes. That is perfectly reasonable for JSON data. It is not a universal policy for every weird object JavaScript can create. This is another reason I think validator comparisons should state the expected input domain. --- 📦 Bundle Size Now the fun table. 😺 Each browser-targeted entry exports only validate. All of them were bundled with the same esbuild settings: - browser platform - ESM - ES2022 - minification - tree shaking - legal comments removed Then I measured raw, gzip, and Brotli output. | Validator | Raw bytes | gzip bytes | Brotli bytes | | ---------- | --------: | ---------: | -----------: | | handwritten guard |       588 |        337 |          295 | | is-kit     |     3,421 |      1,530 |        1,423 | | runtypes   |    16,667 |      5,188 |        4,657 | | valibot   |     5,722 |      1,853 |        1,675 | | zod       |    89,888 |     26,212 |       23,331 | | arktype   |   155,549 |     47,603 |       41,956 | | typia     |       772 |        403 |          339 | A few important caveats. This is standalone cost If your app already ships Zod, the marginal cost of one more schema is not 26 KB gzip. This table measures isolated entry bundles. This is regular Zod Not Zod Mini. So this table does not answer > Valibot vs Zod Mini? Generated does not mean zero bytes typia's final guard bundle was only 772 bytes raw in this test. But it still shipped generated code and a uint32 helper. "Generated validator" does not mean "free validator". Still, the architectural difference is very visible here. --- ⚡ Runtime I almost did not include runtime numbers because validator benchmarks get misleading very quickly. But since the workspace already had the same contract and fixtures, I ran a small steady-state benchmark. Three cases: - valid user - wrong id - wrong nested profile.active Each library ran in a separate Node process. Each case used: - 20,000 warmup calls - 9 measured samples - 50,000 calls per sample Schemas and validators were created outside the measured loop. Results are median nanoseconds per call, with min–max in parentheses. | Validator |                  Valid |              Wrong id |   Wrong profile.active | | ---------- | ---------------------: | ----------------------: | -----------------------: | | handwritten guard |      79.9 (77.3–114.1) |       73.6 (72.1–84.9) |        80.8 (79.0–96.3) | | is-kit     | 232.0 (225.7–257.5) | 34.5 (33.1–38.9) | 210.3 (203.6–218.1) | | runtypes   | 5804.0 (5425.5–6298.0) | 9221.8 (8443.2–11063.2) | 10101.2 (9287.6–10503.5) | | valibot   |    951.2 (425.2–1782.7) |     200.2 (96.1–422.3) |   1112.1 (648.4–1538.1) | | zod       |   612.6 (351.3–1203.5) | 1491.6 (862.9–2824.6) | 2208.5 (1592.4–4757.4) | | arktype   |    291.5 (278.1–614.4) |           9.6 (6.6–17.7) |         12.0 (11.3–37.6) | | typia     |      21.9 (20.5–46.1) |           2.0 (1.9–6.4) |         11.5 (10.8–17.2) | Please do not read this as: > typia is the fastest validator. This is a microbenchmark. The same small object shapes are validated repeatedly, so V8 can optimize very aggressively. I would not interpret a 2.0 ns result as a portable "cost of validation" in a real application. The success APIs are also different: - handwritten guard uses a predicate - is-kit uses a predicate - Runtypes uses .guard() - Valibot uses is() - Zod uses safeParse(...).success - ArkType uses allows() - typia uses a generated equals guard Those operations do not all perform exactly the same work. What the benchmark _does_ show is that architecture and failure path matter. For example: - the handwritten guard pays for exact-key checks before rejecting the wrong id - ArkType's invalid paths were much cheaper than its valid path - typia's generated guard had very low steady-state overhead here - Runtypes' richer validation machinery costs more in this particular hot loop That is useful. A universal speed ranking is not. --- So... Which One Would I Use? 🤔 After doing all of this, my decision tree is actually pretty simple. Just one local narrowing check? Use a handwritten guard. if (isUser(input)) { // input is User } No dependency is a feature when the problem is small. Lots of reusable guards? This is where I use is-kit. 🔧 values.filter(isName); if (isAge(value)) { // ... } The problem is not "I need a schema". The problem is "I keep rebuilding the same predicates and composition helpers". Want a validator object? Runtypes is interesting when you like having one runtime object that can: 🏃 guard check parse inspect assert Want schema validation with a modular API? Valibot. 🤖 Especially if client bundle size and tree shaking are important parts of the design. Want schemas tied into a bigger ecosystem? Zod. 💎 That is probably its strongest reason to exist in many applications. And if bundle size is a concern, evaluate Zod Mini separately. Want the schema syntax to feel like TypeScript? ArkType. 🚢 "18 <= number.integer <= 120"; is a very different authoring experience from chained schema methods. Want TypeScript types transformed into validators during the build? typia. But I would treat the build integration as part of the decision, not a footnote. --- Final Thought These tools overlap, but they live at different layers. The hard part is not making seven validators compile. It is making seven validators mean the same thing. So before comparing package size or benchmark screenshots, ask what the boundary needs to return and what runtime machinery is worth carrying. > Use the smallest model that still represents your boundary well. Then, which one will you use? Thank you for reading! 🤲

    64 reactions · 26 comments
  9. TypeScript 7.1 Can Now Import Almost Anything. So I Made It Import C++, C, and Rust. I've been following the development of Content Mappers in TypeScript for quite a while. Partly because I'm excited about what they make possible, but also because I've been working on another TypeScript extension project that relies heavily on Volar.js. And God, that's a lot of code to maintain just to get all the language features working correctly and keep everything in sync with TypeScript. But with TypeScript 7.1, we finally have an official extension mechanism built directly into the compiler. And I think this opens up some pretty interesting possibilities. What if TypeScript could import anything? Imagine being able to write something like this: import { Rows, Columns } from "./data.csv"; import { exif } from "./photo.jpeg"; import { config } from "./config.yml"; And not just import these files as strings or any, but have TypeScript actually understand their contents. Imagine getting autocomplete for your YAML configuration, properly inferred columns from a CSV file, or type information extracted directly from another file format. No manually maintained .d.ts files. No generic declare module "*.csv" that treats every CSV file identically. Just types derived from the actual content. This is the kind of thing Content Mappers make possible. And it's not entirely theoretical. Motherload, from Colin McDonnell, is already exploring typed imports for several formats. Same for his another work - Reylo, it also utilizes the same approach. But how does this actually work? How TypeScript 7.1 Content Mappers work Until now, TypeScript tooling has mostly been built around TypeScript and JavaScript files. Frameworks like Vue, Svelte, and Astro needed additional tooling to make TypeScript understand code embedded inside their custom file formats. That's where projects like Volar.js came in. Volar made it possible to provide TypeScript language features inside non-TypeScript files, including autocomplete, diagnostics, go-to-definition, and references. However, integrating this with TypeScript's compiler and language server wasn't exactly straightforward. TypeScript 7.1 introduces Content Mappers as a first-class extension point. The idea is surprisingly simple: 1. Register a Content Mapper for one or more file extensions. 2. When TypeScript encounters a matching file, it asks the mapper to process it. 3. The mapper produces a virtual TypeScript representation of the file. 4. TypeScript uses that representation for type checking and language services. But here's the really interesting part. The mapper can also provide source mappings between the virtual TypeScript code and the original file. So TypeScript doesn't just understand the generated declarations. It can connect them back to their original source locations. That means things like hover information, diagnostics, go-to-definition, references, and rename can work across file formats. For example, imagine a Content Mapper that processes this C++ function: // math.cpp int add(int a, int b) { return a + b; } It could expose the following virtual TypeScript declaration: export declare function add( a: number, b: number ): number; Now TypeScript knows the function exists, what arguments it accepts, and what it returns. And because the mapper knows where add, a, and b came from in the original C++ source, editor navigation can work across the language boundary. Pretty cool, right? Of course, Content Mappers don't automatically make arbitrary files executable. They solve the TypeScript understanding and tooling side of the problem. Actually running those imports is still the responsibility of the runtime or another loader. And that's where I started thinking about another problem. What about native code? I'm currently building a small inference engine, and some of its functionality lives in C++. One of my biggest headaches has been integrating that native code with Node.js. Normally, if you want to call a C++ function from Node.js, you need to create native bindings using Node-API (N-API). It works, but it also means writing extra glue code, compiling addons, keeping TypeScript declarations in sync, and dealing with the build process. There are libraries like Koffi that make native interoperability easier, and they're definitely interesting. But I wanted something slightly different. I wanted the native source itself to be the source of truth. What if TypeScript could read C++ function signatures directly? What if I could get IntelliSense, type checking, and go-to-definition without maintaining another layer of definitions? And what if the native bindings could be generated and compiled automatically? Essentially: What if importing a C++ function felt exactly like importing another TypeScript function? Well, I decided to try building it. Introducing ts-polyglot ts-polyglot is an experimental project that lets you import functions from C, C++, and Rust source files directly into TypeScript, with inferred types and native execution through Node.js. Here's what that looks like. First, a regular C function: // add.c int add(int a, int b) { return a + b; } A C++ function: // greet.cpp #include <string> std::string greet(std::string name) { return "Hello, " + name; } And a Rust function: // square.rs pub fn square(n: i32) -> i32 { n * n } Now, from TypeScript: import { add } from "./add.c"; import { greet } from "./greet.cpp"; import { square } from "./square.rs"; console.log(add(2, 3)); // 5 console.log(greet("TypeScript")); // Hello, TypeScript console.log(square(6)); // 36 That's it. No handwritten N-API wrappers. No manually maintained TypeScript declarations. No separate native build command. And these aren't untyped calls. TypeScript understands the imported signatures: add(1, 2); // ✅ add("hello", 2); // ❌ TypeScript error square(5); // ✅ square(true); // ❌ TypeScript error You also get IntelliSense, hover information, and navigation to the original native source. But functions are only part of the story. We can import native structs too One thing I really wanted was to avoid manually recreating C++ data structures in TypeScript. Consider this simple C++ file: // geometry.cpp struct Point { double x; double y; }; double distanceSquared(Point point) { return point.x * point.x + point.y * point.y; } With ts-polyglot, we can import both the function and the native struct type: import { distanceSquared, type Point } from "./geometry.cpp"; const point: Point = { x: 3, y: 4 }; console.log(distanceSquared(point)); // 25 TypeScript understands Point automatically. Under the hood, the Content Mapper generates the equivalent of: export interface Point { x: number; y: number; } export declare function distanceSquared( point: Point ): number; But you never have to write or maintain that interface yourself. Change the native struct, and the generated TypeScript representation changes with it. This also works with supported nested structs and array fields. And importantly, Point isn't just a type-checking trick. When you call distanceSquared(point), ts-polyglot converts the JavaScript object into the native struct expected by C++. Which brings us to the more interesting implementation details. How do we discover which native functions can be imported? This was one of the first interesting challenges. C, C++, and Rust have very different syntax, and simply searching for function definitions with regular expressions obviously isn't going to work. We need to understand the structure of the source code. For that, ts-polyglot uses Tree-sitter. Tree-sitter is a parser system that can generate syntax trees for many programming languages. We use its C, C++, and Rust grammars to parse native source files and find declarations that we can expose to TypeScript. The process looks roughly like this: 1. Parse the native source When ts-polyglot encounters a native file, it passes the source through the appropriate Tree-sitter parser. Tree-sitter gives us a syntax tree containing function definitions, parameter declarations, return types, structs, and other language constructs. For example: struct Point { double x; double y; }; double distanceSquared(Point point) { return point.x * point.x + point.y * point.y; } From this, we can extract information equivalent to: Struct: Point x: double y: double Function: distanceSquared Parameters: point: Point Returns: double The important thing is that we're reading the actual native source, not a separate declaration file. 2. Find supported functions Next, we traverse the syntax tree looking for functions that can be imported. It's not limited to simple top-level functions. For example, ts-polyglot can discover supported C++ functions inside namespaces: namespace math { double square(double x) { return x * x; } } It also handles functions inside extern "C" blocks and public functions inside Rust modules. But finding a function isn't enough. We also need to determine whether its parameters and return type can actually cross the JavaScript/native boundary. A function that accepts a double is relatively straightforward. A function that accepts an arbitrary pointer, a complicated template, or a mutable reference is a very different problem. So before exposing a function, we inspect its signature and check whether every parameter and the return value have a supported type mapping. If the function contains unsupported types, we skip it rather than generating a TypeScript declaration that we can't actually support at runtime. This means you can have a native source file containing many different functions, and ts-polyglot will expose the subset it knows how to handle. 3. Resolve native types into TypeScript types Once we know a function can be imported, we generate its TypeScript signature. For example: bool isPositive(double value); Becomes: export declare function isPositive( value: number ): boolean; For structs, we inspect the fields and recursively map their types too. This is where things become more complicated, because native types don't always have a direct JavaScript equivalent. And that leads to the next big topic. Type mapping: C++ types aren't JavaScript types TypeScript has number, string, boolean, bigint, arrays, objects, and a few other useful abstractions. C, C++, and Rust have much richer and sometimes very different type systems. So we need explicit rules for translating native types into TypeScript types. Here's a simplified overview of what ts-polyglot currently supports: | Native type | TypeScript type | |---|---| | int, int32_t, Rust i32 | number | | float, double, Rust f64 | number | | bool | boolean | | std::string, Rust String | string | | int64_t, Rust i64 | bigint for results | | struct Point | interface Point | | std::vector<double>, Vec<f64> | number[] | | Unsigned byte arrays | Uint8Array | | Fixed-size arrays | TypeScript tuples | There are some important details here. For example, 64-bit integers are represented as bigint for return values and struct fields, while parameters can accept number | bigint where supported. Array parameters are typed as readonly arrays, while returned arrays are mutable. And some native types have no safe mapping yet. You can find the full table of supported mappings here: **ts-polyglot.dev But generating TypeScript types is only half of the problem. At runtime, we also need to convert values in both directions. Type marshaling: getting values across the native boundary This is probably one of the trickiest parts of native interoperability. When TypeScript calls a C++ function, JavaScript values can't simply be passed directly into native code. They have to be converted into their corresponding native representations. This process is called type marshaling. Let's go back to our Point example: const point: Point = { x: 3, y: 4 }; distanceSquared(point); From the TypeScript side, this is just a plain JavaScript object. But our C++ function expects a native Point struct. So the generated Node-API binding needs to do something conceptually like this: 1. Receive the JavaScript object. 2. Verify that it has the expected properties. 3. Read x and y and validate their types. 4. Convert both values into native double values. 5. Construct a native Point struct. 6. Call the actual C++ function. 7. Convert the returned double back into a JavaScript number. And we do the opposite when a native function returns a struct. For example: Point createPoint(double x, double y) { return { x, y }; } From TypeScript: import { createPoint, type Point } from "./geometry.cpp"; const point: Point = createPoint(10, 20); // { x: 10, y: 20 } The generated binding reads the native struct's fields and creates a new JavaScript object. No manually written conversion code. The same principle applies to supported strings, arrays, nested structs, and other types. There are some important safety details too. Conversions are checked at runtime. If you pass a string to a function that expects an integer, the generated binding throws a TypeError. If you pass a number outside the native integer's supported range, it throws a RangeError. Arrays and structs cross the boundary by value, with their contents copied. JavaScript objects aren't simply reinterpreted as native memory. This is important because JavaScript and native code have very different memory models and ownership rules. We want an import to feel like a normal TypeScript function call without silently introducing unsafe conversions. Some mappings are smarter than simple type replacement One example I particularly like is C arrays. Consider: double mean(const double values[], size_t length) { double total = 0; for (size_t i = 0; i < length; i++) { total += values[i]; } return length ? total / length : 0; } Normally, you'd need to pass both the array and its length. But ts-polyglot can recognize supported C array-and-length parameter patterns. So the exposed TypeScript signature becomes: export declare function mean( values: readonly number[] ): number; And you can call it like this: mean([10, 20, 30]); // 20 The generated binding takes care of calculating and passing the length. It's a small example, but it demonstrates that type mapping doesn't have to be just a one-to-one translation of native types. We can expose a more natural TypeScript API while preserving the original native function. From source file to working native import Now we have two important pieces: - Function discovery and type mapping, which tell TypeScript what functions exist and how to use them. - Type marshaling, which tells the runtime how to safely call those functions. Here's how ts-polyglot connects everything. At development time: the Content Mapper When TypeScript encounters: import { distanceSquared, type Point } from "./geometry.cpp"; Our Content Mapper uses Tree-sitter to parse the C++ file, discover supported functions and structs, and generate their virtual TypeScript declarations. It also returns source mappings so TypeScript can associate symbols in those declarations with their original positions in the C++ file. Nothing needs to be compiled at this stage. TypeScript just gets the information it needs for type checking and editor features. At runtime: the Node.js import hook When Node.js encounters the same import, a different part of ts-polyglot takes over. The runtime hook: 1. Parses the native source using the same signature discovery implementation. 2. Generates Node-API bindings for the supported functions. 3. Generates the necessary type marshaling code. 4. Compiles the native code and generated bindings into a .node addon. 5. Loads the addon and exposes its functions as JavaScript exports. For Rust, the build process also involves compiling the Rust code into a static library and linking it with the generated addon. The resulting builds are cached, so subsequent imports of unchanged source files don't need to compile everything again. And there's an important detail here: Both the Content Mapper and runtime use the same Tree-sitter-based signature parser. So the types TypeScript checks are derived from the same native signatures that the runtime uses when generating bindings. This is especially useful because otherwise we'd effectively be maintaining two different interpretations of the same native code. The goal is to have a single source of truth: the native source file itself. Try it yourself The easiest way to create a sample project is: npm create ts-polyglot This scaffolds a project with C, C++, and Rust examples. For an existing project, install: npm install ts-polyglot npm install -D typescript@7.1.0-beta Then register the Content Mapper in tsconfig.json: { "compilerOptions": { "module": "nodenext", "strict": true, "noEmit": true }, "contentMappers": [ { "package": "ts-polyglot", "extensions": [".c", ".cpp", ".rs"] } ] } Type-check your project: npx tsc --runExternalCode The --runExternalCode flag is required because Content Mappers are external executable programs. And run your TypeScript file with the native import hook: node --import ts-polyglot/register main.ts You'll need Node.js 22.18+, a native C/C++ compiler, and rustc if you're importing Rust. For VS Code, use the TypeScript (Native Preview) extension, select your workspace's TypeScript 7.1 version, and enable the mapper in a trusted workspace. It's still early This is very much an experimental project. There are lots of interesting problems we haven't solved yet. Native interoperability gets complicated quickly, especially when pointers, ownership, lifetimes, mutable references, callbacks, and more complicated C++ and Rust constructs enter the picture. At the moment, ts-polyglot focuses on supported functions with well-defined mappings for numbers, booleans, strings, plain structs, and arrays. Things like arbitrary pointers, mutable out-parameters, enums, function pointers, and various templates and generics aren't supported yet. Tree-sitter also isn't a full C++ compiler. It doesn't expand macros or perform complete semantic analysis, so there are limits to what we can discover directly from the source. And TypeScript 7.1's Content Mapper protocol is still in beta, so some integration details may change. But that's part of the experiment. How much native binding boilerplate can we eliminate if TypeScript understands the source directly and the runtime can generate the bridge automatically? Why I think Content Mappers matter What excites me most about TypeScript 7.1 isn't just support for another file extension. It's the possibility of making TypeScript's type system and development tooling available to entirely different languages and file formats through a supported extension mechanism. Today we're talking about Vue components, CSV files, YAML configurations, and native functions. But the same concept could be applied to many other kinds of source files and domain-specific languages. We can start treating these files as first-class participants in a TypeScript project instead of maintaining separate declarations or building completely separate language tooling around them. And with an appropriate runtime integration, those imports can become executable too. For me, ts-polyglot is one experiment in that direction. It's still early, and there's plenty of work ahead, but I think there's something really interesting here. If you've worked with TypeScript language tooling, Volar, Node-API, native interoperability, Tree-sitter, or Content Mappers, I'd love to hear what you think. And if you want to try it or explore the implementation: GitHub: mykhailen/ts-polyglot Website: ts-polyglot.dev Supported types: ts-polyglot.dev TypeScript 7.1 announcement: devblogs.microsoft.com Feedback, ideas, and contributions are welcome!

    1 reaction
  10. 1,391 Tags Later, 931 Still Describe One Post Each On 4 June I closed two doors at once. I pulled tag pages out of the sitemap — thin listings were inflating sixty per cent of it and eating crawl budget. The same day, for the same reason, I put a limit on the production side too: a canonical vocabulary of 45 tags and a cap of five tags per post. The commit message says exactly that: "restrict AI tag generation to a canonical ~46-tag vocabulary." It worked. Two hundred posts shipped in July and added exactly one new tag to the vocabulary. Then in September the tag count started climbing again, and it took me four months to notice. Today I sat down and counted: 1,370 Turkish posts, 1,391 distinct tags. There are more tags than there are posts. The inventory first Numbers from today's main tree, drafts excluded. The live feed.json gives the same — I counted both separately: | Measurement | Value | |---|---| | Turkish posts | 1,370 | | Distinct tags | 1,391 | | Total tag usages | 5,831 | | Tags per post | mean 4.26 · median 5 | | Tags appearing on exactly one post | 931 (66.9% of distinct) | | Appearing on at most two posts | 1,112 (79.9%) | | Share of the 15 most frequent tags | 1,809 usages = 31.0% | The head of the distribution is healthy: yazilim on 245 posts, operations 148, security 143, kariyer 141, linux 140. Fifteen tags carry a third of all usage. The problem isn't at the head; it's in the tail. I also counted where the tail came from: of the 931 single-post tags, 708 sit on posts published before 4 June and 223 after. So the vocabulary I installed that day genuinely held. The problem is the legacy stock — and the fact that the gate stopped working four months later. I didn't start deleting at this point. It felt more honest to ask something else first: who reads this field? Because calling something useless assumes someone was looking at it. Who reads a tag? I found eight places in the code that consume tags. I measured each one separately, because "tags don't do anything" is just a feeling until you check. Two of the results did not confirm what I expected. !Diagram Related posts. The scoring is simple: same category +3, each shared tag +1, top three win. I have to be honest here: taken as a whole, tags work. In 1,358 of 1,370 posts at least one candidate rises above the category-only score thanks to a tag. In only 12 posts does the top score stay at 3 — and there the selection is decided by array order, because hundreds of candidates are tied. Throwing the field away would be wrong. But by definition, a tag that lives on one post can never enter this scoring; it has nobody to share with. The tag page. I wrote this part up last week from the Search Console side, so I'll keep it short: the page ships noindex,follow, it left the sitemap on 4 June, and robots.txt closed it on 6 June. One detail from that post applies here too — and it demolishes what I'd been assuming was three layers of protection: per Google's own documentation, for noindex to work the page must not be blocked in robots.txt. A crawler that can't fetch the page never reads the noindex line. The second rule silences the first. The practical result: a tag page is neither in the sitemap nor crawlable, which leaves the human click as its only real visitor. And when you open the page of a tag that lives on one post, what do you see? A heading saying "1 post", and below it a single card: the post you just left. 931 times. A smaller finding: the card component prints only the first three tags, so 2,102 tag usages (36%) appear on no card at all. And tag links are generated without a trailing slash in four places (under the post, the homepage tag cloud, the blog listing); asking for /tags/olcum earns you a 301 to /tags/olcum/. Only the card component writes it correctly. meta keywords and schema.org. Both are still in the live HTML: <meta name="keywords" content="yazilim-kariyeri, dokumantasyon, olcum, git, surec"> and the same array inside the JSON-LD. Google's documentation on supported meta tags isn't even polite about it: the keywords tag has "no effect on indexing and ranking at all." On the structured-data side, the properties they list for Article/BlogPosting are author, dateModified, datePublished, headline and image — keywords isn't there. Harmless; but the story I'd told myself — "I write tags for SEO" — has no ground under it. Social hashtags. Here there are real consumers, two of them: the Bluesky and LinkedIn code each turn the first five tags into hashtags. 809 single-post tags fall inside that window and 804 sit on posts that were actually shared, so they went out. A hashtag's job is to take you into a feed; a hashtag with one post is a room with a door and nothing behind it. Search. The search text is assembled from title + tags + description + body. A tag only contributes something new if it isn't already in the prose — and "already there" depends on your matching rule: under strict substring matching, 567 of the 931 are present in their own post's text; allowing Turkish suffixes and separator differences, 677. So somewhere between a quarter and a third of the tail genuinely adds a new searchable word — things like invisible workload or customer trust. That's a more honest sentence than "none of it contributes." RSS, JSON Feed and llms-full.txt. I saved the real surprise for last, because before measuring I hadn't even counted these three. All of them emit every tag of every post, with no truncation: the <category> lines in RSS, the tags array in feed.json, the - Tags: line in llms-full.txt. The tail that the card hides and the hashtag window cuts at five is in full circulation here. And llms-full.txt is the very file my robots.txt policy deliberately invites AI assistants to read. So the widest consumer of the field I'd written off as unread turned out to be in the place I never looked. When the gate stopped working New tags by publication month — and I'm giving the table from the start, because the real story isn't where I'd cut it: | Month | Posts | New tags | Per post | |---|---|---|---| | 2026-04 | 368 | 602 | 1.64 | | 2026-05 | 274 | 422 | 1.54 | | 2026-06 | 232 | 51 | 0.22 | | 2026-07 | 200 | 1 | 0.01 | | 2026-08 | 97 | 49 | 0.51 | | 2026-09 | 96 | 140 | 1.46 | | 2026-10 | 71 | 70 | 0.99 | Had I started the table at June, I'd have had a dramatic "September explosion" on my hands. Add the April and May rows and the story changes: September isn't an explosion, it's a return to the pre-4-June baseline. So the question isn't "what happened in September?" but "why did the gate I installed in June stop working?" The answer is mechanical. The canonical vocabulary and the five-tag cap live inside the script that generates posts. On 3 September the topic queue ran dry — I wrote about that separately, because it broke my category rotation the same day — and posts started coming out of a different path that writes the file directly. That path never calls the old script, so it walks straight past the vocabulary and the cap. There are 14 posts carrying nine tags, and all of them are dated 19–28 September: by then the ceiling was already gone. I installed that gate, and I didn't remove it. I just installed it inside the corridor — and when the corridor changed, the gate went with it. The root cause sits one line away So why did nothing catch it? Because the validation isn't in the schema. Inside src/lib/blog-frontmatter-schema.mjs, two neighbouring lines read: category: z.enum(BLOG_CATEGORIES), // closed: technology, tutorials, career, life tags: z.array(z.string()).min(1), // at least one — no ceiling, no vocabulary I locked category to four values. For tags I never wrote anything beyond "there should be at least one." Because the vocabulary decision lived in the generator rather than the schema, this file — which inspects every post on the publishing path — waved 1,391 distinct values through without complaint. I'd seen a similar pattern when I tested my own instruction files against the code: the place nobody audits is the place that quietly drifts. The difference this time: the audit existed, it was just in the wrong layer. Same concept, two separate tags There's no normalisation either. Collapsing case, hyphen, space and Turkish-character differences, 180 clusters are represented by more than one spelling, covering 386 tags. (Drop the Turkish folding and you're left with 156 clusters / 333 tags — that fold is exactly what separates yazilim from yazılım.) The expensive ones: - devops on 124 posts · DevOps on 15 - sistem-mimarisi 123 · sistem mimarisi 5 · Sistem Mimarisi 1 - ai 74 · AI 21 - indie-hacker 95 · indie hacker 10 The tag query uses exact matching (tags.includes(tag)), so these really are different things. I tried it live: /tags/DevOps/ and /tags/devops/ both return 200 and show two separate lists. In the related-posts score those two posts behave as though they share nothing. The hashtag formatter then carries the same split somewhere else: devops becomes #Devops while DevOps becomes #DevOps, yet sistem-mimarisi and sistem mimarisi both collapse into #SistemMimarisi. There's a bilingual duplication on top: kariyer 141 / career 82, security 143 / guvenlik 51, rehber 118 / tutorial 3. Which one is correct is debatable; that using both is wrong isn't. My own note misled me Into the 11 May commit where I moved tag pages to SSR, I wrote this: > Prerendering tag pages per unique tag was producing ~100 pages. In the same commit message I counted posts precisely: "985+ posts x 2 languages = ~1970 pages." I counted one field and estimated its neighbour. I checked out that commit's tree and counted: 910 Turkish + 915 English = 1,825 tag pages. My estimate was off by a factor of eighteen. The fix still worked. But because of that "~100 pages" note I remembered the problem as a small one for months — until three and a half weeks later, on 4 June, I measured the same thing again and wrote the real number into the code. The rounded figure kept feeding the memory even while the measured one sat in a neighbouring file. One more note: in my post on 20 October I wrote "1,261 distinct Turkish tags." Counting today from both the git tree and the live feed.json gives 1,391, and the tree on that date held 1,375. A gap that size can't close in six days, so I'll redo that measurement. The number in this post is the correct one. Four questions I now ask before adding a field For any metadata field — frontmatter, database column, tag, it doesn't matter: 1. Can I name its consumers? I thought there were five; there are eight, and I'd never counted the three widest. 2. Is the vocabulary closed? If not, why not — does it genuinely need infinite variety, or was I avoiding a decision? 3. Which layer does the validation live in? My gate was inside a generation script, not beside the schema. The path changed and the gate went with it, and the schema felt nothing. Put the rule next to whatever records the data, not next to whatever produces it. 4. Does the field leave the building? Even if I close the vocabulary today, the RSS history, the hashtags already posted and the crawlers that have fetched llms-full.txt are out of my hands. Metadata doesn't come back once it has escaped. I also left myself two cheap numbers: the single-use ratio (66.9% here) and the share of the fifteen most frequent values (31.0%). If both climb at once, the vocabulary is coming apart. What I'm going to do Order matters, because clearing stock doesn't fix a mechanism. First I'll move the validation into the schema (an approved list plus lowercase normalisation), so that every path writes through the same gate. Then I'll merge the 180 spelling variants. Pruning the tail comes last — and the safety net for that is already in place: a tag address with no posts 301s to /blog/, so I can delete without creating dead links. I'm not planning to remove tag pages altogether: the listing for a tag like olcum, with 62 posts, is a genuinely useful page. What needs removing isn't the page, it's the tail that makes the page meaningless. The real lesson: I started writing this as "I kept filling a field nobody reads," and the measurement stopped me twice. I had closed the gate — but inside the corridor. And the widest reader of the supposedly unread field turned out to be three machine-facing files I had never looked at. Filling a field doesn't prove it's read; assuming it isn't read doesn't prove that either. Both are things you measure. One small note: this post's own tags were picked from the existing vocabulary. All five have been used before; I didn't add a new one. Official Sources - Meta tags and attributes that Google supports — where it states the keywords meta tag has no effect on indexing or ranking. - Article (Article, NewsArticle, BlogPosting) structured data — the properties Google uses for Article; keywords isn't among them. - Large site owner's guide to managing your crawl budget — how low-value/duplicate URLs affect crawl budget, and why noindex is the wrong tool for crawl management. - schema.org: keywords — the property's definition and how multiple values are supplied. - Zod — schema validation; the difference between a closed value set with z.enum and a free list with z.array(z.string()).

    1 reaction

Achievements

Awards earned by the repositories colinhacks owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.