GCC's printf annotation can give you compile-time safety.

You can get run-time type checking in C using C99 variadic macros and C11 _Generic expressions. Here's a proof-of-concept I threw together earlier this year in a moment of boredom. I only tested it with a few versions of clang and GCC.

#include <assert.h>
#include <errno.h>
#include <stdarg.h> /* va_list va_arg va_end va_start */
#include <stdio.h>
#include <string.h>

#include <netinet/in.h>

#define STRFMT_CALL(F, ...) F(__VA_ARGS__)
#define STRFMT_NARG_(a, b, c, d, e, f, g, h, i, j, N,...) N
#define STRFMT_NARG(...) STRFMT_NARG_(__VA_ARGS__, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0)
#define STRFMT_PASTE(x, y) x##y
#define STRFMT_XPASTE(x, y) STRFMT_PASTE(x, y)
#define STRFMT_STRINGIFY_(s) #s
#define STRFMT_STRINGIFY(s) STRFMT_STRINGIFY_(s)

enum strfmt_type {
STRFMT_NONE = 0,
STRFMT_INT,
STRFMT_UINT,
STRFMT_LONG,
STRFMT_ULONG,
STRFMT_LLONG,
STRFMT_ULLONG,
STRFMT_FLOAT,
STRFMT_DOUBLE,
STRFMT_LDOUBLE,
STRFMT_CHAR_P,
STRFMT_SCHAR_P,
STRFMT_UCHAR_P,
STRFMT_VOID_P,
STRFMT_STRUCT_IN_ADDR_P,
STRFMT_STRUCT_IN6_ADDR_P,
STRFMT_END_P,
};

#define STRFMT_END (&(struct strfmt_end){ 0 })
struct strfmt_end { int _; };

extern int strfmt_unknown_expression_type;
#define strfmt_badtype(X) strfmt_unknown_expression_type

#define strfmt_promote(X) ((0)? 0 : (X))

#define strfmt_typeof_p_g(X, Q, def) \
_Generic(strfmt_promote(X), \
Q char *: STRFMT_CHAR_P, \
Q signed char *: STRFMT_SCHAR_P, \
Q unsigned char *: STRFMT_UCHAR_P, \
Q void *: STRFMT_VOID_P, \
Q struct in_addr *: STRFMT_STRUCT_IN_ADDR_P, \
Q struct in6_addr *: STRFMT_STRUCT_IN6_ADDR_P, \
Q struct strfmt_end *: STRFMT_END_P, \
default: (def))

#define strfmt_typeof_p(X) \
strfmt_typeof_p_g((X), const, \
strfmt_typeof_p_g((X), , strfmt_badtype(X)))

#define strfmt_typeof(X) \
_Generic(strfmt_promote(X), \
int: STRFMT_INT, \
unsigned: STRFMT_UINT, \
long: STRFMT_LONG, \
unsigned long: STRFMT_ULONG, \
long long: STRFMT_LLONG, \
unsigned long long: STRFMT_ULLONG, \
float: STRFMT_FLOAT, \
double: STRFMT_DOUBLE, \
long double: STRFMT_LDOUBLE, \
default: strfmt_typeof_p(X))

#define STRFMT_EXP1(X) strfmt_typeof(X), STRFMT_STRINGIFY(X), (X)
#define STRFMT_EXP2(X, ...) STRFMT_EXP1(X), STRFMT_EXP1(__VA_ARGS__)
#define STRFMT_EXP3(X, ...) STRFMT_EXP1(X), STRFMT_EXP2(__VA_ARGS__)
#define STRFMT_EXP4(X, ...) STRFMT_EXP1(X), STRFMT_EXP3(__VA_ARGS__)
#define STRFMT_EXP5(X, ...) STRFMT_EXP1(X), STRFMT_EXP4(__VA_ARGS__)
#define STRFMT_EXP6(X, ...) STRFMT_EXP1(X), STRFMT_EXP5(__VA_ARGS__)
#define STRFMT_EXP7(X, ...) STRFMT_EXP1(X), STRFMT_EXP6(__VA_ARGS__)
#define STRFMT_EXP8(X, ...) STRFMT_EXP1(X), STRFMT_EXP7(__VA_ARGS__)
#define STRFMT_EXP9(X, ...) STRFMT_EXP1(X), STRFMT_EXP8(__VA_ARGS__)
#define STRFMT_EXPAND(...) STRFMT_CALL(STRFMT_XPASTE(STRFMT_EXP, STRFMT_NARG(__VA_ARGS__)), __VA_ARGS__)

#define strfmt_(dst, lim, error, fmt, ...) strfmt((dst), (lim), (error), (fmt), STRFMT_EXPAND(__VA_ARGS__))
#define strfmt(...) strfmt_(__VA_ARGS__, STRFMT_END)

size_t
(strfmt)(void *dst, size_t lim, int *_error, const char *fmt, ...)
{
enum strfmt_type type;
const char *exp;
va_list ap;
int error;

*_error = 0;
strlcpy(dst, "", lim);

va_start(ap, fmt);

do {
type = va_arg(ap, enum strfmt_type);
exp = va_arg(ap, char *);

switch (type) {
case STRFMT_INT:
va_arg(ap, int);
strlcat(dst, "(int)", lim);
break;
case STRFMT_UINT:
va_arg(ap, unsigned);
strlcat(dst, "(unsigned)", lim);
break;
case STRFMT_LONG:
va_arg(ap, long);
strlcat(dst, "(long)", lim);
break;
case STRFMT_ULONG:
va_arg(ap, unsigned long);
strlcat(dst, "(unsigned long)", lim);
break;
case STRFMT_LLONG:
va_arg(ap, long long);
strlcat(dst, "(long long)", lim);
break;
case STRFMT_ULLONG:
va_arg(ap, unsigned long long);
strlcat(dst, "(unsigned long long)", lim);
break;
case STRFMT_FLOAT:
va_arg(ap, double);
strlcat(dst, "(float)", lim);
break;
case STRFMT_DOUBLE:
va_arg(ap, double);
strlcat(dst, "(double)", lim);
break;
case STRFMT_LDOUBLE:
va_arg(ap, long double);
strlcat(dst, "(long double)", lim);
break;
case STRFMT_CHAR_P:
va_arg(ap, char *);
strlcat(dst, "(char *)", lim);
break;
case STRFMT_SCHAR_P:
va_arg(ap, signed char *);
strlcat(dst, "(signed char *)", lim);
break;
case STRFMT_UCHAR_P:
va_arg(ap, unsigned char *);
strlcat(dst, "(unsigned char *)", lim);
break;
case STRFMT_VOID_P:
va_arg(ap, void *);
strlcat(dst, "(void *)", lim);
break;
case STRFMT_STRUCT_IN_ADDR_P:
va_arg(ap, struct in_addr *);
strlcat(dst, "(struct in_addr *)", lim);
break;
case STRFMT_STRUCT_IN6_ADDR_P:
va_arg(ap, struct in6_addr *);
strlcat(dst, "(struct in6_addr *)", lim);
break;
case STRFMT_END_P:
va_arg(ap, struct strfmt_end *);
break;
default:
fprintf(stderr, "unknown type:%d\n", (int)type);
error = EINVAL;
goto error;
}

if (type != STRFMT_END_P) {
strlcat(dst, exp, lim);
strlcat(dst, ", ", lim);
}
} while (type != STRFMT_END_P);

va_end(ap);

return strlen(dst);
error:
va_end(ap);

return (*_error = error), 0;
}

int
main(void)
{
char buf[1024];
const char *const const_buf = buf;
int error;

strfmt(buf, sizeof buf, &error, "(fmt)",
"string literal",
buf,
const_buf,
(long double)0.0,
(&(struct in_addr){ INADDR_LOOPBACK, }),
(uint64_t)64,
__builtin_nan("")
);
puts(buf);

return 0;
}